]> source.dussan.org Git - jquery.git/commitdiff
Event: Simulate focus/blur in IE via focusin/focusout
authorMichał Gołębiowski-Owczarek <m.goleb@gmail.com>
Mon, 27 Mar 2023 19:22:38 +0000 (21:22 +0200)
committerGitHub <noreply@github.com>
Mon, 27 Mar 2023 19:22:38 +0000 (21:22 +0200)
In IE (all versions), `focus` & `blur` handlers are fired asynchronously
but `focusin` & `focusout` are run synchronously. In other browsers, all
those handlers are fired synchronously. Asynchronous behavior of these
handlers in IE caused issues for IE (gh-4856, gh-4859).

We now simulate `focus` via `focusin` & `blur` via `focusout` in IE to avoid
these issues. This also let us simplify some tests.

This commit also simplifies `leverageNative` - with IE now using `focusin`
to simulate `focus` and `focusout` to simulate `blur`, we don't have to deal
with async events in `leverageNative`. This also fixes broken `focus` triggers
after first triggering it on a hidden element - previously, `leverageNative`
assumed that the native `focus` handler not firing after calling the native
`focus` method meant it would be handled later, asynchronously, which
was not the case (gh-4950).

Fixes gh-4856
Fixes gh-4859
Fixes gh-4950
Closes gh-5223

Co-authored-by: Richard Gibson <richard.gibson@gmail.com>
src/event.js
test/unit/event.js

index 0c66d385600c3ace6ef4c74cd9a5d3c89a47b7c7..95221f475df7623fdc95318d5eabf1263aa92966 100644 (file)
@@ -1,9 +1,9 @@
 import jQuery from "./core.js";
-import document from "./var/document.js";
 import documentElement from "./var/documentElement.js";
 import rnothtmlwhite from "./var/rnothtmlwhite.js";
 import rcheckableType from "./var/rcheckableType.js";
 import slice from "./var/slice.js";
+import isIE from "./var/isIE.js";
 import acceptData from "./data/var/acceptData.js";
 import dataPriv from "./data/var/dataPriv.js";
 import nodeName from "./core/nodeName.js";
@@ -21,16 +21,6 @@ function returnFalse() {
        return false;
 }
 
-// Support: IE <=9 - 11+
-// focus() and blur() are asynchronous, except when they are no-op.
-// So expect focus to be synchronous when the element is already active,
-// and blur to be synchronous when the element is not already active.
-// (focus and blur are always synchronous in other supported browsers,
-// this just defines when we can count on it).
-function expectSync( elem, type ) {
-       return ( elem === document.activeElement ) === ( type === "focus" );
-}
-
 function on( elem, types, selector, data, fn, one ) {
        var origFn, type;
 
@@ -459,7 +449,7 @@ jQuery.event = {
                                        el.click && nodeName( el, "input" ) ) {
 
                                        // dataPriv.set( el, "click", ... )
-                                       leverageNative( el, "click", returnTrue );
+                                       leverageNative( el, "click", true );
                                }
 
                                // Return false to allow normal processing in the caller
@@ -511,10 +501,10 @@ jQuery.event = {
 // synthetic events by interrupting progress until reinvoked in response to
 // *native* events that it fires directly, ensuring that state changes have
 // already occurred before other listeners are invoked.
-function leverageNative( el, type, expectSync ) {
+function leverageNative( el, type, isSetup ) {
 
-       // Missing expectSync indicates a trigger call, which must force setup through jQuery.event.add
-       if ( !expectSync ) {
+       // Missing `isSetup` indicates a trigger call, which must force setup through jQuery.event.add
+       if ( !isSetup ) {
                if ( dataPriv.get( el, type ) === undefined ) {
                        jQuery.event.add( el, type, returnTrue );
                }
@@ -526,15 +516,13 @@ function leverageNative( el, type, expectSync ) {
        jQuery.event.add( el, type, {
                namespace: false,
                handler: function( event ) {
-                       var notAsync, result,
+                       var result,
                                saved = dataPriv.get( this, type );
 
                        if ( ( event.isTrigger & 1 ) && this[ type ] ) {
 
                                // Interrupt processing of the outer synthetic .trigger()ed event
-                               // Saved data should be false in such cases, but might be a leftover capture object
-                               // from an async native handler (gh-4350)
-                               if ( !saved.length ) {
+                               if ( !saved ) {
 
                                        // Store arguments for use when handling the inner native event
                                        // There will always be at least one argument (an event object), so this array
@@ -543,28 +531,17 @@ function leverageNative( el, type, expectSync ) {
                                        dataPriv.set( this, type, saved );
 
                                        // Trigger the native event and capture its result
-                                       // Support: IE <=9 - 11+
-                                       // focus() and blur() are asynchronous
-                                       notAsync = expectSync( this, type );
                                        this[ type ]();
                                        result = dataPriv.get( this, type );
-                                       if ( saved !== result || notAsync ) {
-                                               dataPriv.set( this, type, false );
-                                       } else {
-                                               result = {};
-                                       }
+                                       dataPriv.set( this, type, false );
+
                                        if ( saved !== result ) {
 
                                                // Cancel the outer synthetic event
                                                event.stopImmediatePropagation();
                                                event.preventDefault();
 
-                                               // Support: Chrome 86+
-                                               // In Chrome, if an element having a focusout handler is blurred by
-                                               // clicking outside of it, it invokes the handler synchronously. If
-                                               // that handler calls `.remove()` on the element, the data is cleared,
-                                               // leaving `result` undefined. We need to guard against this.
-                                               return result && result.value;
+                                               return result;
                                        }
 
                                // If this is an inner synthetic event for an event with a bubbling surrogate
@@ -582,16 +559,11 @@ function leverageNative( el, type, expectSync ) {
                        } else if ( saved.length ) {
 
                                // ...and capture the result
-                               dataPriv.set( this, type, {
-                                       value: jQuery.event.trigger(
-
-                                               // Support: IE <=9 - 11+
-                                               // Extend with the prototype to reset the above stopImmediatePropagation()
-                                               jQuery.extend( saved[ 0 ], jQuery.Event.prototype ),
-                                               saved.slice( 1 ),
-                                               this
-                                       )
-                               } );
+                               dataPriv.set( this, type, jQuery.event.trigger(
+                                       saved[ 0 ],
+                                       saved.slice( 1 ),
+                                       this
+                               ) );
 
                                // Abort handling of the native event
                                event.stopImmediatePropagation();
@@ -724,6 +696,29 @@ jQuery.each( {
 }, jQuery.event.addProp );
 
 jQuery.each( { focus: "focusin", blur: "focusout" }, function( type, delegateType ) {
+
+       // Support: IE 11+
+       // Attach a single focusin/focusout handler on the document while someone wants focus/blur.
+       // This is because the former are synchronous in IE while the latter are async. In other
+       // browsers, all those handlers are invoked synchronously.
+       function focusMappedHandler( nativeEvent ) {
+
+               // `eventHandle` would already wrap the event, but we need to change the `type` here.
+               var event = jQuery.event.fix( nativeEvent );
+               event.type = nativeEvent.type === "focusin" ? "focus" : "blur";
+               event.isSimulated = true;
+
+               // focus/blur don't bubble while focusin/focusout do; simulate the former by only
+               // invoking the handler at the lower level.
+               if ( event.target === event.currentTarget ) {
+
+                       // The setup part calls `leverageNative`, which, in turn, calls
+                       // `jQuery.event.add`, so event handle will already have been set
+                       // by this point.
+                       dataPriv.get( this, "handle" )( event );
+               }
+       }
+
        jQuery.event.special[ type ] = {
 
                // Utilize native event if possible so blur/focus sequence is correct
@@ -732,10 +727,15 @@ jQuery.each( { focus: "focusin", blur: "focusout" }, function( type, delegateTyp
                        // Claim the first handler
                        // dataPriv.set( this, "focus", ... )
                        // dataPriv.set( this, "blur", ... )
-                       leverageNative( this, type, expectSync );
+                       leverageNative( this, type, true );
+
+                       if ( isIE ) {
+                               this.addEventListener( delegateType, focusMappedHandler );
+                       } else {
 
-                       // Return false to allow normal processing in the caller
-                       return false;
+                               // Return false to allow normal processing in the caller
+                               return false;
+                       }
                },
                trigger: function() {
 
@@ -746,6 +746,16 @@ jQuery.each( { focus: "focusin", blur: "focusout" }, function( type, delegateTyp
                        return true;
                },
 
+               teardown: function() {
+                       if ( isIE ) {
+                               this.removeEventListener( delegateType, focusMappedHandler );
+                       } else {
+
+                               // Return false to indicate standard teardown should be applied
+                               return false;
+                       }
+               },
+
                // Suppress native focus or blur if we're currently inside
                // a leveraged native-event stack
                _default: function( event ) {
index 8ca06ced8e141a30ff5b19d91d9dfcd377c8a842..aa55c06a1d4ad0a65567bc4ca42603df81164806 100644 (file)
@@ -2172,12 +2172,12 @@ QUnit.test( "focusin bubbles", function( assert ) {
 
 // Removed since DOM focus is unreliable on test swarm
        // DOM focus method
-//     input[0].focus();
+//     input[ 0 ].focus();
 
        // To make the next focus test work, we need to take focus off the input.
        // This will fire another focusin event, so set order to reflect that.
 //     order = 1;
-//     jQuery("#text1")[0].focus();
+//     jQuery( "#text1" )[ 0 ].focus();
 
        // jQuery trigger, which calls DOM focus
        order = 0;
@@ -2187,6 +2187,42 @@ QUnit.test( "focusin bubbles", function( assert ) {
        jQuery( "body" ).off( "focusin.focusinBubblesTest" );
 } );
 
+QUnit.test( "focus does not bubble", function( assert ) {
+       assert.expect( 1 );
+
+       var done = assert.async(),
+               input = jQuery( "<input type='text' />" ).prependTo( "body" );
+
+       // focus the element so DOM focus won't fire
+       input[ 0 ].focus();
+
+       jQuery( "body" ).on( "focus.focusDoesNotBubbleTest", function() {
+               assert.ok( false, "focus doesn't fire on body" );
+       } );
+
+       input.on( "focus.focusDoesNotBubbleTest", function() {
+               assert.ok( true, "focus on the element" );
+       } );
+
+// Removed since DOM focus is unreliable on test swarm
+       // DOM focus method
+//     input[ 0 ].focus();
+
+       // To make the next focus test work, we need to take focus off the input.
+       // This will fire another focusin event, so set order to reflect that.
+//     jQuery( "#text1" )[ 0 ].focus();
+
+       // jQuery trigger, which calls DOM focus
+       input.trigger( "focus" );
+
+       input.remove();
+       jQuery( "body" ).off( "focus.focusDoesNotBubbleTest" );
+
+       setTimeout( function() {
+               done();
+       }, 50 );
+} );
+
 QUnit.test( "custom events with colons (trac-3533, trac-8272)", function( assert ) {
        assert.expect( 1 );
 
@@ -2652,6 +2688,10 @@ QUnit.test( "element removed during focusout (gh-4417)", function( assert ) {
        button[ 0 ].blur = function() {
                jQuery.cleanData( [ this ] );
                this.parentNode.removeChild( this );
+
+               // Redefine `blur` to avoid a hard crash in Karma tests that stop
+               // the test runner in case this test fails.
+               this.blur = jQuery.noop;
        };
 
        button[ 0 ].click();
@@ -3067,12 +3107,7 @@ QUnit.test( "focusout/focusin support", function( assert ) {
        var focus,
                parent = jQuery( "<div>" ),
                input = jQuery( "<input>" ),
-               inputExternal = jQuery( "<input>" ),
-
-               // Support: IE <=9 - 11+
-               // focus and blur events are asynchronous; this is the resulting mess.
-               // The browser window must be topmost for this to work properly!!
-               done = assert.async();
+               inputExternal = jQuery( "<input>" );
 
        parent.append( input );
        jQuery( "#qunit-fixture" ).append( parent ).append( inputExternal );
@@ -3080,61 +3115,54 @@ QUnit.test( "focusout/focusin support", function( assert ) {
        // initially, lose focus
        inputExternal[ 0 ].focus();
 
-       setTimeout( function() {
-               parent
-                       .on( "focus", function() {
-                               assert.ok( false, "parent: focus not fired" );
-                       } )
-                       .on( "focusin", function() {
-                               assert.ok( true, "parent: focusin fired" );
-                       } )
-                       .on( "blur", function() {
-                               assert.ok( false, "parent: blur not fired" );
-                       } )
-                       .on( "focusout", function() {
-                               assert.ok( true, "parent: focusout fired" );
-                       } );
-
-               input
-                       .on( "focus", function() {
-                               assert.ok( true, "element: focus fired" );
-                       } )
-                       .on( "focusin", function() {
-                               assert.ok( true, "element: focusin fired" );
-                               focus = true;
-                       } )
-                       .on( "blur", function() {
-                               assert.ok( true, "parent: blur fired" );
-                       } )
-                       .on( "focusout", function() {
-                               assert.ok( true, "element: focusout fired" );
-                       } );
-
-               // gain focus
-               input[ 0 ].focus();
+       parent
+               .on( "focus", function() {
+                       assert.ok( false, "parent: focus not fired" );
+               } )
+               .on( "focusin", function() {
+                       assert.ok( true, "parent: focusin fired" );
+               } )
+               .on( "blur", function() {
+                       assert.ok( false, "parent: blur not fired" );
+               } )
+               .on( "focusout", function() {
+                       assert.ok( true, "parent: focusout fired" );
+               } );
 
-               // then lose it
-               inputExternal[ 0 ].focus();
+       input
+               .on( "focus", function() {
+                       assert.ok( true, "element: focus fired" );
+               } )
+               .on( "focusin", function() {
+                       assert.ok( true, "element: focusin fired" );
+                       focus = true;
+               } )
+               .on( "blur", function() {
+                       assert.ok( true, "parent: blur fired" );
+               } )
+               .on( "focusout", function() {
+                       assert.ok( true, "element: focusout fired" );
+               } );
 
-               setTimeout( function() {
+       // gain focus
+       input[ 0 ].focus();
 
-                       // DOM focus is unreliable in TestSwarm
-                       if ( QUnit.isSwarm && !focus ) {
-                               assert.ok( true, "GAP: Could not observe focus change" );
-                               assert.ok( true, "GAP: Could not observe focus change" );
-                               assert.ok( true, "GAP: Could not observe focus change" );
-                               assert.ok( true, "GAP: Could not observe focus change" );
-                               assert.ok( true, "GAP: Could not observe focus change" );
-                               assert.ok( true, "GAP: Could not observe focus change" );
-                       }
+       // then lose it
+       inputExternal[ 0 ].focus();
 
-                       // cleanup
-                       parent.off();
-                       input.off();
+       // DOM focus is unreliable in TestSwarm
+       if ( QUnit.isSwarm && !focus ) {
+               assert.ok( true, "GAP: Could not observe focus change" );
+               assert.ok( true, "GAP: Could not observe focus change" );
+               assert.ok( true, "GAP: Could not observe focus change" );
+               assert.ok( true, "GAP: Could not observe focus change" );
+               assert.ok( true, "GAP: Could not observe focus change" );
+               assert.ok( true, "GAP: Could not observe focus change" );
+       }
 
-                       done();
-               }, 50 );
-       }, 50 );
+       // cleanup
+       parent.off();
+       input.off();
 } );
 
 QUnit.test( "focus-blur order (trac-12868)", function( assert ) {
@@ -3142,56 +3170,45 @@ QUnit.test( "focus-blur order (trac-12868)", function( assert ) {
 
        var order,
                $text = jQuery( "#text1" ),
-               $radio = jQuery( "#radio1" ),
-
-               // Support: IE <=9 - 11+
-               // focus and blur events are asynchronous; this is the resulting mess.
-               // The browser window must be topmost for this to work properly!!
-               done = assert.async();
+               $radio = jQuery( "#radio1" );
 
        $radio[ 0 ].focus();
 
-       setTimeout( function() {
-
-               $text
-                       .on( "focus", function() {
-                               assert.equal( order++, 1, "text focus" );
-                       } )
-                       .on( "blur", function() {
-                               assert.equal( order++, 0, "text blur" );
-                       } );
-               $radio
-                       .on( "focus", function() {
-                               assert.equal( order++, 1, "radio focus" );
-                       } )
-                       .on( "blur", function() {
-                               assert.equal( order++, 0, "radio blur" );
-                       } );
+       $text
+               .on( "focus", function() {
+                       assert.equal( order++, 1, "text focus" );
+               } )
+               .on( "blur", function() {
+                       assert.equal( order++, 0, "text blur" );
+               } );
+       $radio
+               .on( "focus", function() {
+                       assert.equal( order++, 1, "radio focus" );
+               } )
+               .on( "blur", function() {
+                       assert.equal( order++, 0, "radio blur" );
+               } );
 
-               // Enabled input getting focus
-               order = 0;
-               assert.equal( document.activeElement, $radio[ 0 ], "radio has focus" );
-               $text.trigger( "focus" );
-               setTimeout( function() {
+       // Enabled input getting focus
+       order = 0;
+       assert.equal( document.activeElement, $radio[ 0 ], "radio has focus" );
+       $text.trigger( "focus" );
 
-                       // DOM focus is unreliable in TestSwarm
-                       if ( QUnit.isSwarm && order === 0 ) {
-                               assert.ok( true, "GAP: Could not observe focus change" );
-                               assert.ok( true, "GAP: Could not observe focus change" );
-                       }
+       // DOM focus is unreliable in TestSwarm
+       if ( QUnit.isSwarm && order === 0 ) {
+               assert.ok( true, "GAP: Could not observe focus change" );
+               assert.ok( true, "GAP: Could not observe focus change" );
+       }
 
-                       assert.equal( document.activeElement, $text[ 0 ], "text has focus" );
+       assert.equal( document.activeElement, $text[ 0 ], "text has focus" );
 
-                       // Run handlers without native method on an input
-                       order = 1;
-                       $radio.triggerHandler( "focus" );
+       // Run handlers without native method on an input
+       order = 1;
+       $radio.triggerHandler( "focus" );
 
-                       // Clean up
-                       $text.off();
-                       $radio.off();
-                       done();
-               }, 50 );
-       }, 50 );
+       // Clean up
+       $text.off();
+       $radio.off();
 } );
 
 QUnit.test( "Event handling works with multiple async focus events (gh-4350)", function( assert ) {
@@ -3199,10 +3216,6 @@ QUnit.test( "Event handling works with multiple async focus events (gh-4350)", f
 
        var remaining = 3,
                input = jQuery( "#name" ),
-
-               // Support: IE <=9 - 11+
-               // focus and blur events are asynchronous; this is the resulting mess.
-               // The browser window must be topmost for this to work properly!!
                done = assert.async();
 
        input
@@ -3212,6 +3225,17 @@ QUnit.test( "Event handling works with multiple async focus events (gh-4350)", f
                        if ( remaining > 0 ) {
                                input.trigger( "blur" );
                        } else {
+
+                               if ( QUnit.isIE ) {
+
+                                       // Support: <=IE 11+
+                                       // In IE, one of the blurs sometimes triggers a focus on body
+                                       // which in turn restores focus to the input, leading to 4 assertions
+                                       // firing instead of three. This only happens if other tests are
+                                       // running on the same test page. Avoid this issue in tests by removing
+                                       // the handler early.
+                                       input.off( "focus" );
+                               }
                                done();
                        }
                } )
@@ -3237,6 +3261,45 @@ QUnit.test( "Event handling works with multiple async focus events (gh-4350)", f
        } );
 } );
 
+// Support: IE <=9 - 11+
+// focus and blur events are asynchronous.
+// The browser window must be topmost for this to work properly!!
+QUnit.test( "async focus queues properly (gh-4859)", function( assert ) {
+       assert.expect( 1 );
+
+       var $text = jQuery( "#text1" ),
+               $radio = jQuery( "#radio1" ),
+               done = assert.async();
+
+       $text.trigger( "focus" );
+       $radio.trigger( "focus" );
+       $text.trigger( "focus" );
+
+       setTimeout( function() {
+               assert.equal( document.activeElement, $text[ 0 ], "focus follows the last trigger" );
+               done();
+       }, 500 );
+} );
+
+// Support: IE <=9 - 11+
+// focus and blur events are asynchronous.
+// The browser window must be topmost for this to work properly!!
+QUnit.test( "async focus queues properly with blur (gh-4856)", function( assert ) {
+       assert.expect( 1 );
+
+       var $text = jQuery( "#text1" ),
+               done = assert.async();
+
+       $text.trigger( "focus" );
+       $text.trigger( "blur" );
+       $text.trigger( "focus" );
+
+       setTimeout( function() {
+               assert.equal( document.activeElement, $text[ 0 ], "focus-after-blur is respected" );
+               done();
+       }, 500 );
+} );
+
 QUnit.test( "native-backed events preserve trigger data (gh-1741, gh-4139)", function( assert ) {
        assert.expect( 17 );
 
@@ -3246,12 +3309,7 @@ QUnit.test( "native-backed events preserve trigger data (gh-1741, gh-4139)", fun
                targets = jQuery( parent[ 0 ].childNodes ),
                checkbox = jQuery( targets[ 0 ] ),
                data = [ "arg1", "arg2" ],
-               slice = data.slice,
-
-               // Support: IE <=9 - 11+
-               // focus and blur events are asynchronous; this is the resulting mess.
-               // The browser window must be topmost for this to work properly!!
-               done = assert.async();
+               slice = data.slice;
 
        // click (gh-4139)
        assert.strictEqual( targets[ 0 ].checked, false, "checkbox unchecked before click" );
@@ -3277,18 +3335,26 @@ QUnit.test( "native-backed events preserve trigger data (gh-1741, gh-4139)", fun
                var type = event.type;
                assert.deepEqual( slice.call( arguments, 1 ), data,
                        type + " handler received correct data" );
+
+               if ( QUnit.isIE && type === "focus" ) {
+
+                       // Support: <=IE 11+
+                       // In IE, one of the blurs sometimes triggers a focus on body
+                       // which in turn restores focus to the input, leading to 4 assertions
+                       // firing instead of three. This only happens if other tests are
+                       // running on the same test page. Avoid this issue in tests by removing
+                       // the handler early.
+                       checkbox.off( "focus" );
+               }
        } );
        checkbox.trigger( "focus", data );
-       setTimeout( function() {
-               assert.strictEqual( document.activeElement, checkbox[ 0 ],
-                       "element focused after focus event (default action)" );
-               checkbox.trigger( "blur", data );
-               setTimeout( function() {
-                       assert.notEqual( document.activeElement, checkbox[ 0 ],
-                               "element not focused after blur event (default action)" );
-                       done();
-               }, 50 );
-       }, 50 );
+
+       assert.strictEqual( document.activeElement, checkbox[ 0 ],
+               "element focused after focus event (default action)" );
+       checkbox.trigger( "blur", data );
+
+       assert.notEqual( document.activeElement, checkbox[ 0 ],
+               "element not focused after blur event (default action)" );
 } );
 
 QUnit.test( "focus change during a focus handler (gh-4382)", function( assert ) {
@@ -3341,6 +3407,22 @@ QUnit.test( "trigger(focus) works after .on(focus).off(focus) (gh-4867)", functi
        assert.equal( document.activeElement, input[ 0 ], "input has focus" );
 } );
 
+QUnit.test( "trigger(focus) works after focusing when hidden (gh-4950)", function( assert ) {
+       assert.expect( 1 );
+
+       var input = jQuery( "<input />" );
+
+       input.appendTo( "#qunit-fixture" );
+
+       input
+               .css( "display", "none" )
+               .trigger( "focus" )
+               .css( "display", "" )
+               .trigger( "focus" );
+
+       assert.equal( document.activeElement, input[ 0 ], "input has focus" );
+} );
+
 // TODO replace with an adaptation of
 // https://github.com/jquery/jquery/pull/1367/files#diff-a215316abbaabdf71857809e8673ea28R2464
 ( function() {