sequential.dev

Capturing DOM events

articlejavascriptprogramming

There are a few use cases for which we might want to attach handlers to events globally.

You may want to generalize DOM updates based on event type, without attaching handlers atomically, or perhaps you simply want to track mouse movements.

The one way one might imagine doing this is just to handle the events after propagation.

That is, in a parent element.

Some jquery-esque code:

<div class="parent">Parent<span></span>
<div class="child">Child<span></span>
</div>
</div>
var $stopPropagation = $('input');

function clickHandler (event) {
console.log("event fired");
}

$('.parent').on('click', clickHandler);
$('.child').on('click', clickHandler);

This would work fine, and we would see any clicks on the child be propagated up, first being handled by the child elements handler and then by the parent handler.

Unfortunately, a developer could stop this with a simple change. Stopping the event from ever reaching a handler one DOM layer up.

function clickHandler (event) {
console.log("event fired");
// stop default action behavior from occuring
event.preventDefault();
// stop bubbling to the parent
event.stopPropogation();
}

A developer could take this a step further, and stop even lateral event propagation to other handlers for the same event in the same child component.

function clickHandler (event) {
console.log("event fired");
// stop default action behavior from occuring
event.preventDefault();
// stop bubbling to the parent + other handlers for `event`
event.stopImmediatePropogation();
}

This makes it chaotic and unreliable to track DOM events by trusting event bubbling or handler chaining.

In fact, the only way to track all events reliably and without obstruction, would be to jack into the process when events are registered. We can do this with a sneaky override of a function in EventTarget called addEventListener.

Looking at the call signature for addEventListener, we see that it takes the event handler function as a second argument.

addEventListener(type, listener); 

We can wrap this handler in our own customer handler that does the work we need, before calling the original handler function.

This will ensure that events are registered with our custom functionality.

In code:

var eventListenerProto = EventTarget.prototype.addEventListener;

function logEvent(eventName) {
console.log("An event occurred: ", eventName);
}
EventTarget.prototype.addEventListener = function(eventName, eventHandler)
{
eventListenerProto.call(this, eventName, function(event) {
// our injection -> we have to ensure this is not breaking
logEvent(eventName);
eventHandler(event);
});
};

This seems to work, but a reasonable extension would be to extend the solution to a React (for my own devious purposes). I wonder if React fiddles with event bubbling enough such that this will no longer work?

References

Update

Wiring our "event hoist" into a sample React app (https://github.com/ahfarmer/calculator) shows that it does in fact work!

Although React does seem to have some idiosyncrasies. As an example, what is react-invokeguardedcallback? It seems to be an event that is fired multiple times after a react-click event. Perhaps it is a mechanism to prevent bubbling through the DOM that is executed at each level? Seems like a problem for a different day.

Update #2

On a second look, it seems that our first approach does capture the events, but blocks the child handler from being executed in React.
An improved and working solution is as follows:

var eventListenerProto = EventTarget.prototype.addEventListener;

EventTarget.prototype.addEventListener = function(
  eventName,
  eventHandler,
  capture,
) {
  console.log(
    `type: ${eventName}, handler: ${eventHandler}, capture: ${capture}`,
  );
this.eventListenerProto = eventListenerProto;
  this.eventListenerProto(eventName, eventHandler, capture);
};