# Unpatching Event Listeners

Event listeners in Angular are relatively easy to work with, but it's important to consider that event listeners can trigger change detection cycles. Examples of this can be found in the DOM Events section. Personally, I prefer to avoid manually adding event listeners because it requires explicit removal.

Angular utilizes the EventManager (opens new window) for event handling. Specifically, the EventManager works with the renderer, and the renderer interacts with the EventManager. The EventManager maintains a list of plugins that can handle different types of events. Plugins are classes that implement the supports() method, which determines whether a particular event is supported or not. Consider the following example:

class MyPluginThatHandlesSuperCoolEvent {
  addEventListener() { ... }

  supports(eventName: string): boolean {
    return eventName === 'superCoolEvent';
  }
}

When the superCoolEvent event listener is declared in the template, it goes through the ɵɵlistener instruction, which then goes through the renderer and event manager. The event manager iterates through its list of plugins, calling the .supports(eventName) method on each plugin to determine compatibility.

In Angular, it is possible to retrieve the original event listener using special tokens. We can create a custom plugin that adds event listeners using an unpatched DOM API. Additionally, we want to ensure that markForCheck is not called before the actual listener is invoked, even if the listener is added through an unpatched API. Let's create a plugin and examine its arguments:

import { EVENT_MANAGER_PLUGINS } from '@angular/platform-browser';

@Injectable()
export class SilentEventPlugin {
  private readonly modifier = 'silent';

  addEventListener(
    element: HTMLElement,
    eventName: string,
    wrappedWithPreventDefaultListener: Function,
  ) {
    console.log(wrappedWithPreventDefaultListener);
    return () => {};
  }

  supports(eventName: string): boolean {
    return eventName.endsWith(this.modifier);
  }
}

@NgModule({
  providers: [{ provide: EVENT_MANAGER_PLUGINS, useClass: SilentEventPlugin, multi: true }],
})
export class AppModule {}

Note: we have to add any event listener in the template, for instance, <div (click.silent)="onClick()"></div>.

The console.log(wrappedWithPreventDefaultListener) will log a function returned by decoratePreventDefault. You can navigate to this function to see its implementation and comments from the Angular team:

function decoratePreventDefault(eventHandler) {
  return event => {
    if (event === '__ngUnwrap__') {
      return eventHandler;
    }
    const allowDefaultBehavior = eventHandler(event);
    if (allowDefaultBehavior === false) {
      event.preventDefault();
      event.returnValue = false;
    }
    return undefined;
  };
}

The implementation suggests that we can invoke the wrappedWithPreventDefaultListener function with the __ngUnwrap__ parameter to retrieve the eventHandler. This indicates that wrappedWithPreventDefaultListener is a wrapper function that modifies the behavior of the original event handler by preventing the default action of the event.

By passing __ngUnwrap__ as an argument, we can access the original event handler, which allows us to perform additional actions or modifications if needed:

console.log(wrappedWithPreventDefaultListener('__ngUnwrap__'));

This will log a function wrapListenerIn_markDirtyAndPreventDefault. This is what basically acts as a ubiquitous event handler for all events occuring in any Angular application. Let's see its implementation:

function wrapListener(tNode, lView, context, listenerFn, wrapWithPreventDefault) {
  return function wrapListenerIn_markDirtyAndPreventDefault(e) {
    if (e === Function) {
      return listenerFn;
    }
    const startView =
      tNode.flags & 2 /* TNodeFlags.isComponentHost */
        ? getComponentLViewByIndex(tNode.index, lView)
        : lView;
    markViewDirty(startView);
    let result = executeListenerWithErrorHandling(lView, context, listenerFn, e);
    let nextListenerFn = wrapListenerIn_markDirtyAndPreventDefault.__ngNextListenerFn__;
    while (nextListenerFn) {
      result = executeListenerWithErrorHandling(lView, context, nextListenerFn, e) && result;
      nextListenerFn = nextListenerFn.__ngNextListenerFn__;
    }
    if (wrapWithPreventDefault && result === false) {
      e.preventDefault();
      e.returnValue = false;
    }
    return result;
  };
}

The inclusion of markViewDirty in the event handler invokes markForCheck() before calling the actual event listener, which is typically a class method.

The while (nextListenerFn) loop indicates that there may be multiple event listeners associated with the same event on the element. In the provided example <button (click)="onClick1()" (click)="onClick2()"></button>, both onClick1() and onClick2() will be executed sequentially within the same tick when the button is clicked.

Chaining the calls for wrappedWithPreventDefaultListener with __ngUnwrap__ and Function allows us to retrieve the original function that was wrapped by wrappedWithPreventDefaultListener:

console.log(wrappedWithPreventDefaultListener('__ngUnwrap__')(Function));

This should log the following:

function ComponentName_Template_div_click_silent_NUMBER_listener() {
  return ctx.onClick();
}

That function calls the class method directly without handling preventDefault and marking views as dirty. Now let's write the complete plugin code:

import { PLATFORM_ID } from '@angular/core';
import { DOCUMENT, ɵgetDOM, isPlatformServer } from '@angular/common';
import { EventManager } from '@angular/platform-browser';

function unwrap(eventName: string, modifier: string): string {
  // click.silent -> click
  return eventName.replace(`.${modifier}`, '');
}

const originalListenerToken = '__ngUnwrap__';

function unwrapOriginalListener(wrappedWithPreventDefaultListener: Function): EventListener {
  const wrappedWithMarkDirtyAndPreventDefaultListener =
    wrappedWithPreventDefaultListener(originalListenerToken);

  const originalListener = wrappedWithMarkDirtyAndPreventDefaultListener(Function);

  return originalListener;
}

@Injectable()
export class SilentEventPlugin {
  // This will be assigned by `EventManager` so we're safe to use the `!` operator.
  manager!: EventManager;

  private readonly modifier = 'silent';

  private readonly document = inject(DOCUMENT);
  private readonly platformId = inject(PLATFORM_ID);

  addEventListener(
    element: HTMLElement,
    eventName: string,
    wrappedWithPreventDefaultListener: Function,
  ): Function {
    if (isPlatformServer(this.platformId)) {
      // Do nothing.
      return () => {};
    }

    eventName = unwrap(eventName, this.modifier);

    const originalListener = unwrapOriginalListener(wrappedWithPreventDefaultListener);

    // Call `element.__zone_symbol__addEventListener()`.
    element[Zone.__symbol__('addEventListener') as 'addEventListener'](eventName, originalListener);

    return () =>
      element[Zone.__symbol__('removeEventListener') as 'removeEventListener'](
        eventName,
        originalListener,
      );
  }

  addGlobalEventListener(
    element: string,
    eventName: string,
    wrappedWithPreventDefaultListener: Function,
  ): Function {
    if (isPlatformServer(this.platformId)) {
      // Do nothing.
      return () => {};
    }

    return this.addEventListener(
      ɵgetDOM().getGlobalEventTarget(this.document, element),
      eventName,
      wrappedWithPreventDefaultListener,
    );
  }

  supports(eventName: string): boolean {
    return eventName.endsWith(this.modifier);
  }
}

One real-life example could be collecting user actions for Segment. Suppose we want to track user actions such as clicks, hovers, etc., and send them to Segment. This process should not trigger change detections since these asynchronous tasks are unrelated to view updates in Angular. There might be multiple change detections in a row, considering what Segment is doing internally when making an HTTP (XHR) request:

@Component({
  template: `
    <app-search-filters (mouseenter.silent)="trackSearchFiltersHovered()"></app-search-filters>
  `,
})
export class AppComponent {
  trackSearchFiltersHovered(): void {
    window.analytics.track('...');
  }
}

# Unpatching Key Events

What if we'd want to unpatch specific key events that might be added through the keyup.enter syntax (this syntax is handled by the KeyEventsPlugin). I'll leave the code below since it's implemented the same way as in the example above.

Collapse the example

import { PLATFORM_ID } from '@angular/core';
import { DOCUMENT, ɵgetDOM, isPlatformServer } from '@angular/common';
import { EventManager, ɵKeyEventsPlugin } from '@angular/platform-browser';

function unwrap(eventName: string, modifier: string): string {
  // keyup.enter.silent -> keyup.enter
  return eventName.replace(`.${modifier}`, '');
}

const originalListenerToken = '__ngUnwrap__';

function unwrapOriginalListener(wrappedWithPreventDefaultListener: Function): EventListener {
  const wrappedWithMarkDirtyAndPreventDefaultListener =
    wrappedWithPreventDefaultListener(originalListenerToken);

  const originalListener = wrappedWithMarkDirtyAndPreventDefaultListener(Function);

  return originalListener;
}

@Injectable()
export class SilentKeyEventPlugin {
  // This will be assigned by `EventManager` so we're safe to use the `!` operator.
  manager!: EventManager;

  private readonly modifier = 'silent';

  private readonly document = inject(DOCUMENT);
  private readonly platformId = inject(PLATFORM_ID);

  addEventListener(
    element: HTMLElement,
    eventName: string,
    wrappedWithPreventDefaultListener: Function,
  ) {
    if (isPlatformServer(this.platformId)) {
      // Do nothing.
      return () => {};
    }

    const originalListener = unwrapOriginalListener(wrappedWithPreventDefaultListener);

    const { /* enter */ fullKey, /* keyup */ domEventName } = ɵKeyEventsPlugin.parseEventName(
      unwrap(eventName, this.modifier),
    )!;

    const handler = ((event: KeyboardEvent) => {
      // For Angular < 14.2.0
      if (ɵKeyEventsPlugin.getEventFullKey(event) === fullKey) {
        originalListener(event);
      }

      // For Angular >= 14.2.0
      if (ɵKeyEventsPlugin.matchEventFullKeyCode(event, fullKey)) {
        originalListener(event);
      }
    }) as EventListener;

    // Call `element.__zone_symbol__addEventListener()`.
    element[Zone.__symbol__('addEventListener') as 'addEventListener'](domEventName, handler);

    return () =>
      element[Zone.__symbol__('removeEventListener') as 'removeEventListener'](
        domEventName,
        handler,
      );
  }

  addGlobalEventListener(
    element: string,
    eventName: string,
    wrappedWithPreventDefaultListener: Function,
  ): Function {
    if (isPlatformServer(this.platformId)) {
      // Do nothing.
      return () => {};
    }

    return this.addEventListener(
      ɵgetDOM().getGlobalEventTarget(this.document, element),
      eventName,
      wrappedWithPreventDefaultListener,
    );
  }

  supports(eventName: string): boolean {
    return (
      eventName.endsWith(this.modifier) &&
      ɵKeyEventsPlugin.parseEventName(unwrap(eventName, this.modifier)) !== null
    );
  }
}