Skip to content

Commit f6e7e83

Browse files
committed
Add test for changes
1 parent 87f4824 commit f6e7e83

File tree

2 files changed

+105
-5
lines changed

2 files changed

+105
-5
lines changed

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

Lines changed: 1 addition & 1 deletion
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
}

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

Lines changed: 104 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,104 @@ 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 (enabled: isFocused)
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+
expect(handlerSpies.size).toBe(1);
420+
let [button1Spy] = [...handlerSpies.values()];
421+
expect(button1Spy).toHaveBeenCalledTimes(1); // Called once because modality changed to keyboard
422+
423+
// Track initial calls to button1Spy
424+
const button1CallsBeforeTab = button1Spy.mock.calls.length;
425+
426+
// Tab to second button - first handler should be removed, second added
427+
await user.tab();
428+
expect(button1).not.toHaveAttribute('data-focus-visible');
429+
expect(button2).toHaveAttribute('data-focus-visible');
430+
431+
// After the tab, button1's handler should be removed and button2's added
432+
expect(handlerSpies.size).toBe(1);
433+
434+
// The keyboard event during tab triggers button1's handler before it's removed
435+
// This is expected, handlers are only active when focused
436+
expect(button1Spy.mock.calls.length).toBe(button1CallsBeforeTab + 1);
437+
438+
let [button2Spy] = [...handlerSpies.values()];
439+
expect(button2Spy).not.toBe(button1Spy); // Should be a different spy
440+
441+
button1Spy.mockClear();
442+
button2Spy.mockClear();
443+
444+
// Now verify that button1's handler is NOT called for subsequent events
445+
// since it's no longer keyboard focused
446+
await user.click(button2);
447+
expect(button1).not.toHaveAttribute('data-focus-visible');
448+
expect(button2).not.toHaveAttribute('data-focus-visible');
449+
expect(button1Spy).toHaveBeenCalledTimes(0); // button1's handler should NOT be called
450+
expect(button2Spy).toHaveBeenCalledTimes(1); // Only button2's handler called to change modality to pointer
451+
452+
// Cleanup
453+
unmount();
454+
changeHandlers.add = originalAdd;
455+
changeHandlers.delete = originalDelete;
456+
});
457+
});
358458
});

0 commit comments

Comments
 (0)