1111 */
1212import { act , fireEvent , pointerMap , render , renderHook , screen , waitFor } from '@react-spectrum/test-utils-internal' ;
1313import { addWindowFocusTracking , useFocusVisible , useFocusVisibleListener } from '../' ;
14- import { hasSetupGlobalListeners } from '../src/useFocusVisible' ;
14+ import { changeHandlers , hasSetupGlobalListeners } from '../src/useFocusVisible' ;
1515import { mergeProps } from '@react-aria/utils' ;
1616import React from 'react' ;
1717import { 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