Skip to content

Commit cef3714

Browse files
authored
Refactor Virtualizer to improve performance, stability, and complexity (#6451)
1 parent 0e847d1 commit cef3714

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+1132
-2009
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
"@babel/preset-react": "^7.24.1",
7575
"@babel/preset-typescript": "^7.24.1",
7676
"@babel/register": "^7.23.7",
77+
"@faker-js/faker": "^8.4.1",
7778
"@octokit/rest": "*",
7879
"@parcel/bundler-library": "2.11.1-dev.3224",
7980
"@parcel/optimizer-data-url": "2.0.0-dev.1601",

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

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ interface IFocusContext {
6565
}
6666

6767
const FocusContext = React.createContext<IFocusContext | null>(null);
68+
const RESTORE_FOCUS_EVENT = 'react-aria-focus-scope-restore';
6869

6970
let activeScope: ScopeRef = null;
7071

@@ -117,12 +118,21 @@ export function FocusScope(props: FocusScopeProps) {
117118
// Find all rendered nodes between the sentinels and add them to the scope.
118119
let node = startRef.current?.nextSibling!;
119120
let nodes: Element[] = [];
121+
let stopPropagation = e => e.stopPropagation();
120122
while (node && node !== endRef.current) {
121123
nodes.push(node as Element);
124+
// Stop custom restore focus event from propagating to parent focus scopes.
125+
node.addEventListener(RESTORE_FOCUS_EVENT, stopPropagation);
122126
node = node.nextSibling as Element;
123127
}
124128

125129
scopeRef.current = nodes;
130+
131+
return () => {
132+
for (let node of nodes) {
133+
node.removeEventListener(RESTORE_FOCUS_EVENT, stopPropagation);
134+
}
135+
};
126136
}, [children]);
127137

128138
useActiveScopeTracker(scopeRef, restoreFocus, contain);
@@ -470,7 +480,7 @@ function focusElement(element: FocusableElement | null, scroll = false) {
470480
}
471481
}
472482

473-
function focusFirstInScope(scope: Element[], tabbable:boolean = true) {
483+
function getFirstInScope(scope: Element[], tabbable = true) {
474484
let sentinel = scope[0].previousElementSibling!;
475485
let scopeRoot = getScopeRoot(scope);
476486
let walker = getFocusableTreeWalker(scopeRoot, {tabbable}, scope);
@@ -485,7 +495,11 @@ function focusFirstInScope(scope: Element[], tabbable:boolean = true) {
485495
nextNode = walker.nextNode();
486496
}
487497

488-
focusElement(nextNode as FocusableElement);
498+
return nextNode as FocusableElement;
499+
}
500+
501+
function focusFirstInScope(scope: Element[], tabbable:boolean = true) {
502+
focusElement(getFirstInScope(scope, tabbable));
489503
}
490504

491505
function useAutoFocus(scopeRef: RefObject<Element[]>, autoFocus?: boolean) {
@@ -692,7 +706,7 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus?: boolean,
692706
let treeNode = clonedTree.getTreeNode(scopeRef);
693707
while (treeNode) {
694708
if (treeNode.nodeToRestore && treeNode.nodeToRestore.isConnected) {
695-
focusElement(treeNode.nodeToRestore);
709+
restoreFocusToElement(treeNode.nodeToRestore);
696710
return;
697711
}
698712
treeNode = treeNode.parent;
@@ -703,7 +717,8 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus?: boolean,
703717
treeNode = clonedTree.getTreeNode(scopeRef);
704718
while (treeNode) {
705719
if (treeNode.scopeRef && treeNode.scopeRef.current && focusScopeTree.getTreeNode(treeNode.scopeRef)) {
706-
focusFirstInScope(treeNode.scopeRef.current, true);
720+
let node = getFirstInScope(treeNode.scopeRef.current, true);
721+
restoreFocusToElement(node);
707722
return;
708723
}
709724
treeNode = treeNode.parent;
@@ -715,6 +730,15 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus?: boolean,
715730
}, [scopeRef, restoreFocus]);
716731
}
717732

733+
function restoreFocusToElement(node: FocusableElement) {
734+
// Dispatch a custom event that parent elements can intercept to customize focus restoration.
735+
// For example, virtualized collection components reuse DOM elements, so the original element
736+
// might still exist in the DOM but representing a different item.
737+
if (node.dispatchEvent(new CustomEvent(RESTORE_FOCUS_EVENT, {bubbles: true, cancelable: true}))) {
738+
focusElement(node);
739+
}
740+
}
741+
718742
/**
719743
* Create a [TreeWalker]{@link https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker}
720744
* that matches all focusable/tabbable elements.

packages/@react-aria/focus/test/FocusScope.test.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {Provider} from '@react-spectrum/provider';
1919
import React, {useEffect, useState} from 'react';
2020
import ReactDOM from 'react-dom';
2121
import {Example as StorybookExample} from '../stories/FocusScope.stories';
22+
import {useEvent} from '@react-aria/utils';
2223
import userEvent from '@testing-library/user-event';
2324

2425

@@ -764,6 +765,70 @@ describe('FocusScope', function () {
764765
expect(document.activeElement).toBe(button2);
765766
expect(input1).not.toBeInTheDocument();
766767
});
768+
769+
it('should allow restoration to be overridden with a custom event', async function () {
770+
function Test() {
771+
let [show, setShow] = React.useState(false);
772+
let ref = React.useRef(null);
773+
useEvent(ref, 'react-aria-focus-scope-restore', e => {
774+
e.preventDefault();
775+
});
776+
777+
return (
778+
<div ref={ref}>
779+
<button onClick={() => setShow(true)}>Show</button>
780+
{show && <FocusScope restoreFocus>
781+
<input autoFocus onKeyDown={() => setShow(false)} />
782+
</FocusScope>}
783+
</div>
784+
);
785+
}
786+
787+
let {getByRole} = render(<Test />);
788+
let button = getByRole('button');
789+
await user.click(button);
790+
791+
let input = getByRole('textbox');
792+
expect(document.activeElement).toBe(input);
793+
794+
await user.keyboard('{Escape}');
795+
act(() => jest.runAllTimers());
796+
expect(input).not.toBeInTheDocument();
797+
expect(document.activeElement).toBe(document.body);
798+
});
799+
800+
it('should not bubble focus scope restoration event out of nested focus scopes', async function () {
801+
function Test() {
802+
let [show, setShow] = React.useState(false);
803+
let ref = React.useRef(null);
804+
useEvent(ref, 'react-aria-focus-scope-restore', e => {
805+
e.preventDefault();
806+
});
807+
808+
return (
809+
<div ref={ref}>
810+
<FocusScope>
811+
<button onClick={() => setShow(true)}>Show</button>
812+
{show && <FocusScope restoreFocus>
813+
<input autoFocus onKeyDown={() => setShow(false)} />
814+
</FocusScope>}
815+
</FocusScope>
816+
</div>
817+
);
818+
}
819+
820+
let {getByRole} = render(<Test />);
821+
let button = getByRole('button');
822+
await user.click(button);
823+
824+
let input = getByRole('textbox');
825+
expect(document.activeElement).toBe(input);
826+
827+
await user.keyboard('{Escape}');
828+
act(() => jest.runAllTimers());
829+
expect(input).not.toBeInTheDocument();
830+
expect(document.activeElement).toBe(button);
831+
});
767832
});
768833

769834
describe('auto focus', function () {

packages/@react-aria/selection/src/useSelectableCollection.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
293293
};
294294

295295
// Store the scroll position so we can restore it later.
296+
/// TODO: should this happen all the time??
296297
let scrollPos = useRef({top: 0, left: 0});
297298
useEvent(scrollRef, 'scroll', isVirtualized ? null : () => {
298299
scrollPos.current = {
@@ -342,7 +343,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
342343
scrollRef.current.scrollLeft = scrollPos.current.left;
343344
}
344345

345-
if (!isVirtualized && manager.focusedKey != null) {
346+
if (manager.focusedKey != null) {
346347
// Refocus and scroll the focused item into view if it exists within the scrollable region.
347348
let element = scrollRef.current.querySelector(`[data-key="${CSS.escape(manager.focusedKey.toString())}"]`) as HTMLElement;
348349
if (element) {
@@ -400,17 +401,21 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
400401
// eslint-disable-next-line react-hooks/exhaustive-deps
401402
}, []);
402403

403-
// If not virtualized, scroll the focused element into view when the focusedKey changes.
404-
// When virtualized, Virtualizer handles this internally.
404+
// Scroll the focused element into view when the focusedKey changes.
405405
let lastFocusedKey = useRef(manager.focusedKey);
406406
useEffect(() => {
407-
let modality = getInteractionModality();
408-
if (manager.isFocused && manager.focusedKey != null && scrollRef?.current) {
409-
let element = scrollRef.current.querySelector(`[data-key="${CSS.escape(manager.focusedKey.toString())}"]`) as HTMLElement;
410-
if (element && (modality === 'keyboard' || autoFocusRef.current)) {
411-
if (!isVirtualized) {
412-
scrollIntoView(scrollRef.current, element);
413-
}
407+
if (manager.isFocused && manager.focusedKey != null && manager.focusedKey !== lastFocusedKey.current && scrollRef?.current) {
408+
let modality = getInteractionModality();
409+
let element = ref.current.querySelector(`[data-key="${CSS.escape(manager.focusedKey.toString())}"]`) as HTMLElement;
410+
if (!element) {
411+
// If item element wasn't found, return early (don't update autoFocusRef and lastFocusedKey).
412+
// The collection may initially be empty (e.g. virtualizer), so wait until the element exists.
413+
return;
414+
}
415+
416+
if (modality === 'keyboard' || autoFocusRef.current) {
417+
scrollIntoView(scrollRef.current, element);
418+
414419
// Avoid scroll in iOS VO, since it may cause overlay to close (i.e. RAC submenu)
415420
if (modality !== 'virtual') {
416421
scrollIntoViewport(element, {containingElement: ref.current});
@@ -425,7 +430,13 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
425430

426431
lastFocusedKey.current = manager.focusedKey;
427432
autoFocusRef.current = false;
428-
}, [isVirtualized, scrollRef, manager.focusedKey, manager.isFocused, ref]);
433+
});
434+
435+
// Intercept FocusScope restoration since virtualized collections can reuse DOM nodes.
436+
useEvent(ref, 'react-aria-focus-scope-restore', e => {
437+
e.preventDefault();
438+
manager.setFocused(true);
439+
});
429440

430441
let handlers = {
431442
onKeyDown,

packages/@react-aria/utils/src/platform.ts

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -26,40 +26,54 @@ function testPlatform(re: RegExp) {
2626
: false;
2727
}
2828

29-
export function isMac() {
30-
return testPlatform(/^Mac/i);
29+
function cached(fn: () => boolean) {
30+
if (process.env.NODE_ENV === 'test') {
31+
return fn;
32+
}
33+
34+
let res: boolean | null = null;
35+
return () => {
36+
if (res == null) {
37+
res = fn();
38+
}
39+
return res;
40+
};
3141
}
3242

33-
export function isIPhone() {
43+
export const isMac = cached(function () {
44+
return testPlatform(/^Mac/i);
45+
});
46+
47+
export const isIPhone = cached(function () {
3448
return testPlatform(/^iPhone/i);
35-
}
49+
});
3650

37-
export function isIPad() {
51+
export const isIPad = cached(function () {
3852
return testPlatform(/^iPad/i) ||
3953
// iPadOS 13 lies and says it's a Mac, but we can distinguish by detecting touch support.
4054
(isMac() && navigator.maxTouchPoints > 1);
41-
}
55+
});
4256

43-
export function isIOS() {
57+
export const isIOS = cached(function () {
4458
return isIPhone() || isIPad();
45-
}
59+
});
4660

47-
export function isAppleDevice() {
61+
export const isAppleDevice = cached(function () {
4862
return isMac() || isIOS();
49-
}
63+
});
5064

51-
export function isWebKit() {
65+
export const isWebKit = cached(function () {
5266
return testUserAgent(/AppleWebKit/i) && !isChrome();
53-
}
67+
});
5468

55-
export function isChrome() {
69+
export const isChrome = cached(function () {
5670
return testUserAgent(/Chrome/i);
57-
}
71+
});
5872

59-
export function isAndroid() {
73+
export const isAndroid = cached(function () {
6074
return testUserAgent(/Android/i);
61-
}
75+
});
6276

63-
export function isFirefox() {
77+
export const isFirefox = cached(function () {
6478
return testUserAgent(/Firefox/i);
65-
}
79+
});

packages/@react-aria/utils/src/useEvent.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {useEffectEvent} from './useEffectEvent';
1515

1616
export function useEvent<K extends keyof GlobalEventHandlersEventMap>(
1717
ref: RefObject<EventTarget>,
18-
event: K,
18+
event: K | (string & {}),
1919
handler?: (this: Document, ev: GlobalEventHandlersEventMap[K]) => any,
2020
options?: boolean | AddEventListenerOptions
2121
) {

packages/@react-aria/virtualizer/src/ScrollView.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ function ScrollView(props: ScrollViewProps, ref: RefObject<HTMLDivElement>) {
155155
updateSize();
156156
}, [updateSize]);
157157
let raf = useRef<ReturnType<typeof requestAnimationFrame> | null>();
158-
let onResize = () => {
158+
let onResize = useCallback(() => {
159159
if (isOldReact) {
160160
raf.current ??= requestAnimationFrame(() => {
161161
updateSize();
@@ -164,7 +164,7 @@ function ScrollView(props: ScrollViewProps, ref: RefObject<HTMLDivElement>) {
164164
} else {
165165
updateSize();
166166
}
167-
};
167+
}, [updateSize]);
168168
useResizeObserver({ref, onResize});
169169
useEffect(() => {
170170
return () => {

0 commit comments

Comments
 (0)