Skip to content

Commit 369b703

Browse files
fix useFocusRing perf (#9456)
* fix useFocusRing perf * Add test for changes * fix tests for react 17 --------- Co-authored-by: Robert Snow <[email protected]>
1 parent 9c917c3 commit 369b703

File tree

3 files changed

+110
-7
lines changed

3 files changed

+110
-7
lines changed

packages/@react-aria/focus/src/useFocusRing.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,15 @@ export function useFocusRing(props: AriaFocusRingProps = {}): FocusRingAria {
5151

5252
let onFocusChange = useCallback(isFocused => {
5353
state.current.isFocused = isFocused;
54+
state.current.isFocusVisible = isFocusVisible();
5455
setFocused(isFocused);
5556
updateState();
5657
}, [updateState]);
5758

5859
useFocusVisibleListener((isFocusVisible) => {
5960
state.current.isFocusVisible = isFocusVisible;
6061
updateState();
61-
}, [], {isTextInput});
62+
}, [isTextInput, isFocused], {enabled: isFocused, isTextInput});
6263

6364
let {focusProps} = useFocus({
6465
isDisabled: within,

packages/@react-aria/interactions/src/useFocusVisible.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export interface FocusVisibleResult {
3939

4040
let currentModality: null | Modality = null;
4141
let currentPointerType: PointerType = 'keyboard';
42-
let changeHandlers = new Set<Handler>();
42+
export const changeHandlers = new Set<Handler>();
4343
interface GlobalListenerData {
4444
focus: () => void
4545
}
@@ -333,10 +333,13 @@ export function useFocusVisible(props: FocusVisibleProps = {}): FocusVisibleResu
333333
/**
334334
* Listens for trigger change and reports if focus is visible (i.e., modality is not pointer).
335335
*/
336-
export function useFocusVisibleListener(fn: FocusVisibleHandler, deps: ReadonlyArray<any>, opts?: {isTextInput?: boolean}): void {
336+
export function useFocusVisibleListener(fn: FocusVisibleHandler, deps: ReadonlyArray<any>, opts?: {enabled?: boolean, isTextInput?: boolean}): void {
337337
setupGlobalFocusEvents();
338338

339339
useEffect(() => {
340+
if (opts?.enabled === false) {
341+
return;
342+
}
340343
let handler = (modality: Modality, e: HandlerEvent) => {
341344
// We want to early return for any keyboard events that occur inside text inputs EXCEPT for Tab and Escape
342345
if (!isKeyboardFocusEvent(!!(opts?.isTextInput), modality, e)) {
@@ -351,3 +354,4 @@ export function useFocusVisibleListener(fn: FocusVisibleHandler, deps: ReadonlyA
351354
// eslint-disable-next-line react-hooks/exhaustive-deps
352355
}, deps);
353356
}
357+

packages/@react-aria/interactions/test/useFocusVisible.test.js

Lines changed: 102 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212
import {act, fireEvent, pointerMap, render, renderHook, screen, waitFor} from '@react-spectrum/test-utils-internal';
1313
import {addWindowFocusTracking, useFocusVisible, useFocusVisibleListener} from '../';
14-
import {hasSetupGlobalListeners} from '../src/useFocusVisible';
14+
import {changeHandlers, hasSetupGlobalListeners} from '../src/useFocusVisible';
1515
import {mergeProps} from '@react-aria/utils';
1616
import React from 'react';
1717
import {useButton} from '@react-aria/button';
@@ -65,7 +65,7 @@ describe('useFocusVisible', function () {
6565
beforeAll(() => {
6666
user = userEvent.setup({delay: null, pointerMap});
6767
});
68-
68+
6969
beforeEach(() => {
7070
fireEvent.focus(document.body);
7171
});
@@ -84,7 +84,7 @@ describe('useFocusVisible', function () {
8484
render(<Example />);
8585
await user.tab();
8686
let el = screen.getByText('example-focusVisible');
87-
87+
8888
await user.click(el);
8989
toggleBrowserTabs();
9090

@@ -165,7 +165,7 @@ describe('useFocusVisible', function () {
165165

166166
await user.click(el);
167167
expect(el.textContent).toBe('example');
168-
168+
169169
// Focus events after beforeunload no longer work
170170
fireEvent(iframe.contentWindow, new Event('beforeunload'));
171171
await user.keyboard('{Enter}');
@@ -355,4 +355,102 @@ describe('useFocusVisibleListener', function () {
355355
expect(fnMock).toHaveBeenCalledTimes(3);
356356
expect(fnMock.mock.calls).toEqual([[true], [true], [false]]);
357357
});
358+
359+
describe('subscription model', function () {
360+
let user;
361+
beforeAll(() => {
362+
user = userEvent.setup({delay: null, pointerMap});
363+
});
364+
365+
function Example(props) {
366+
return (
367+
<div>
368+
<ButtonExample data-testid="button1" />
369+
<ButtonExample data-testid="button2" />
370+
</div>
371+
);
372+
}
373+
374+
function ButtonExample(props) {
375+
const ref = React.useRef(null);
376+
const {buttonProps} = useButton({}, ref);
377+
const {focusProps, isFocusVisible} = useFocusRing();
378+
379+
return <button {...mergeProps(props, buttonProps, focusProps)} data-focus-visible={isFocusVisible || undefined} ref={ref}>example</button>;
380+
}
381+
it('does not call changeHandlers when unneeded', async function () {
382+
// Save original methods
383+
const originalAdd = changeHandlers.add.bind(changeHandlers);
384+
const originalDelete = changeHandlers.delete.bind(changeHandlers);
385+
// Map so we can also track references to the original handlers to remove them later
386+
const handlerSpies = new Map();
387+
388+
// Intercept handler registration and wrap with spy
389+
changeHandlers.add = function (handler) {
390+
const spy = jest.fn(handler);
391+
handlerSpies.set(handler, spy);
392+
return originalAdd.call(this, spy);
393+
};
394+
395+
changeHandlers.delete = function (handler) {
396+
const spy = handlerSpies.get(handler);
397+
if (spy) {
398+
handlerSpies.delete(handler);
399+
return originalDelete.call(this, spy);
400+
}
401+
return originalDelete.call(this, handler);
402+
};
403+
404+
// Possibly a little extra cautious with the unmount, but better safe than sorry with cleanup.
405+
let {getByTestId, unmount} = render(<Example />);
406+
407+
let button1 = getByTestId('button1');
408+
let button2 = getByTestId('button2');
409+
expect(button1).not.toHaveAttribute('data-focus-visible');
410+
expect(button2).not.toHaveAttribute('data-focus-visible');
411+
// No handlers registered yet because nothing is focused
412+
expect(handlerSpies.size).toBe(0);
413+
414+
// Tab to first button, this should add its handler
415+
await user.tab();
416+
expect(document.activeElement).toBe(button1);
417+
expect(button1).toHaveAttribute('data-focus-visible');
418+
expect(button2).not.toHaveAttribute('data-focus-visible');
419+
420+
expect(handlerSpies.size).toBe(1);
421+
let [button1Spy] = [...handlerSpies.values()];
422+
423+
button1Spy.mockClear();
424+
425+
// Tab to second button - first handler should be removed, second added
426+
await user.tab();
427+
expect(button1).not.toHaveAttribute('data-focus-visible');
428+
expect(button2).toHaveAttribute('data-focus-visible');
429+
430+
// Still only 1 handler registered (swapped from button1 to button2)
431+
expect(handlerSpies.size).toBe(1);
432+
let [button2Spy] = [...handlerSpies.values()];
433+
expect(button2Spy).not.toBe(button1Spy); // Should be a different spy
434+
435+
// button1's handler was called during tab (keydown/keyup before removal)
436+
// the handler is removed later in an effect
437+
expect(button1Spy.mock.calls.length).toBeGreaterThan(0);
438+
439+
button1Spy.mockClear();
440+
button2Spy.mockClear();
441+
442+
// After button1's handler is removed, it should NOT be called
443+
// for subsequent modality changes - only button2's handler should be called
444+
await user.click(button2);
445+
expect(button1).not.toHaveAttribute('data-focus-visible');
446+
expect(button2).not.toHaveAttribute('data-focus-visible');
447+
expect(button1Spy).toHaveBeenCalledTimes(0); // button1's handler should NOT be called
448+
expect(button2Spy).toHaveBeenCalledTimes(1); // Only button2's handler called to change modality to pointer
449+
450+
// Cleanup
451+
unmount();
452+
changeHandlers.add = originalAdd;
453+
changeHandlers.delete = originalDelete;
454+
});
455+
});
358456
});

0 commit comments

Comments
 (0)