Host Bindings Don't Prevent Default Event Behavior Until After All Event Handlers Have Executed In Angular 7.1.1
The title of this post may be a little bit misleading - this post is about an event-binding caveat on Angular elements, so the details don't all fit nicely into a one-line summary. But, essentially, with the way host bindings work in Angular, if you return "false" from an event-binding handler, Angular will automatically call the event.preventDefault() method on the associated event. However, if you have multiple event-bindings listening to the same event on the same element, the implicit call to event.preventDefault() does not get executed until after all of the local event-handlers have been run. As such, one event-handler won't be able to detect whether or not its sibling event-handler is attempting to prevent an event's default behavior.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
To see this in action, we can setup multiple "click" bindings on the same element in an Angular template. In the first click handler, we can prevent the default behavior. And, in the second click handler, we can check to see if the event's default behavior has been prevented.
In the following code, I'm using two different techniques to prevent an events default behavior. In the first test, I'm returning "false" so that Angular will implicitly prevent the default. And, in the second test, I'm explicitly calling event.preventDefault():
// Import the core angular services.
import { Component } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "my-app",
styleUrls: [ "./app.component.less" ],
template:
`
<div (click)="handleContainer( $event )">
<div class="child">
Use nothing.
</div>
<div
(click)="handleChild( $event, false )"
(click)="handleSibling( $event )"
class="child">
Use "return false" to prevent default.
</div>
<div
(click)="handleChild( $event, true )"
(click)="handleSibling( $event )"
class="child">
Use "event.preventDefault()" to prevent default.
</div>
</div>
`
})
export class AppComponent {
// I log the click event at the container level (one level up in the DOM).
public handleContainer( event: MouseEvent ) : void {
console.group( "Container" );
console.log( "event.returnValue:", event.returnValue );
console.log( "event.defaultPrevented:", event.defaultPrevented );
console.groupEnd();
}
// I am the first click-handler - I prevent the default behavior using two
// different approaches.
public handleChild( event: MouseEvent, explicit: boolean ) : false | void {
if ( explicit ) {
console.group( "First Click Handler In handleChild()" );
console.warn( "Using event.preventDefault() to prevent default." );
console.groupEnd();
// Prevent default using explicit event method.
event.preventDefault();
} else {
console.group( "First Click Handler In handleChild()" );
console.warn( "Using return( false ) to prevent default." );
// Prevent default using the implicit understanding that returning "false"
// from a host-binding will automatically prevent the default behavior on
// the associated event object.
console.groupEnd();
return( false );
}
}
// I am the second click-handler on the child element. I am a sibling to the handler
// that is preventing the default click-event behavior.
public handleSibling( event: MouseEvent ) : void {
console.group( "Sibling" );
console.log( "event.returnValue:", event.returnValue );
console.log( "event.defaultPrevented:", event.defaultPrevented );
console.groupEnd();
}
}
As you can see, the first click-handler method, handleChild(), takes the event object and prevents the default behavior. The second click-handler on the same element, handleSibling(), then inspects the event object to see if the behavior change can be detected.
Now, if we run this in the browser and click on the three different links (one control and two tests), we get the following output:
As you can see, if we return "false" in order to leverage the implicit logic in Angular's host bindings, the sibling click-handler does not see that the event's default behavior has been prevented. Of course, if we explicitly call event.preventDefault(), the sibling click-handler will see the altered behavior. And, in all cases, the event's default behavior will be modified by the time the event bubbles up to the parent container in the Document Object Model (DOM).
Now, to a large degree, this behavior may be moot as I don't believe that Angular makes any explicit guarantees about the order in which host bindings on a given element will be invoked. But, if you're down in the weeds, really trying to hack something together (as I sometimes am), this caveat is important to understand. Especially if you're piggy-backing on an Angular provided directive.
Want to use code from this post? Check out the license.
Reader Comments
Interesting! I'm wondering if the same thing would happen if the events are called from the same attribute? For example:
ng-click="handleChild($event, false);handleSibling($event)"
My assumption would be yes, since you're passing the same
$event
to each, and the issue only occurs when you're usingreturn(false)
to invoke it.Good stuff, Ben!
@Steve,
Interesting question -- to be honest, I completely forgot you could even put more than one expression inside the evaluated attribute :D
Ultimately, what brought this to my attention is that I am trying to tap into the
[routerLink][fragment]
directive that is provided by theRouterModule
. It uses thereturn( false )
approach. And, if I also provide a similar directive (on the same element), I cannot see if thatevent
has been altered.I think my approach, in that case, will be to go up one level in the DOM and capture the
click
event there. We'll see.@All,
Understanding this
event.defaultPrevented
behavior was useful in my polyfill for thefragment
portion of theRouterLink
directive. Right now, as of Angular 7.1.1, second clicks of a fragment link are still ignored. So, I polyfilled the logic:www.bennadel.com/blog/3537-polyfilling-the-second-click-of-a-routerlink-fragment-in-angular-7-1-1.htm
This creates a
(click)
handler on thea[routerLink][fragment]
selector; then, tracks theclick
event as it bubbles up the DOM. Once it gets the parent element, I can check to see if thedefaultPrevented
property is set.I wonder if this has to do with zone.js? It does put off certain behaviors until the next "tick". Totally conjecture and not sure how one would test this. The overall conjecture is that maybe it adds all event handlers to the queue to run after the next tick, and simply runs them all at that point "outside"/regardless of what Angular says.
If you can think of a way to test this I would love to see it!
@Zachary,
To be honest, a bunch of the Zone.js is over my head. As is some of the DOM-events plug-in code that I was seeing that was taking or not-taking the Zone into account. Furthermore, I see some behaviors in Angular that I can't really explain at all. For example, when you execute a Navigation, there is call in the Router that creates a
Promise
. The resolution of that Promise seems to happens in between local-element event handlers and the eventual propagation of the event up the DOM tree .... which makes no sense since the propagation up the DOM tree should happen synchronously before the Promise ever resolves.I know none of that makes sense without code and context. But, I say that all to say that I do think that Angular is doing some tricky / unexpected things under the hood to coordinate all of the events. It's some very complex code under the hood.