10

I created a decorator to help me with handling desktop/mobile events

import { HostListener } from '@angular/core';

type MobileAwareEventName =
  | 'clickstart'
  | 'clickmove'
  | 'clickend'
  | 'document:clickstart'
  | 'document:clickmove'
  | 'document:clickend'
  | 'window:clickstart'
  | 'window:clickmove'
  | 'window:clickend';

export const normalizeEventName = (eventName: string) => {
  return typeof document.ontouchstart !== 'undefined'
    ? eventName
        .replace('clickstart', 'touchstart')
        .replace('clickmove', 'touchmove')
        .replace('clickend', 'touchend')
    : eventName
        .replace('clickstart', 'mousedown')
        .replace('clickmove', 'mousemove')
        .replace('clickend', 'mouseup');
};

export const MobileAwareHostListener = (
  eventName: MobileAwareEventName,
  args?: string[],
) => {
  return HostListener(normalizeEventName(eventName), args);
};

The problem with that is when I try to compile with --prod, I get the following error

typescript error
Error encountered resolving symbol values statically. Function calls are not supported. Consider replacing
the function or lambda with a reference to an exported function (position 26:40 in the original .ts file),
resolving symbol MobileAwareHostListener in
.../event-listener.decorator.ts, resolving symbol HomePage in
.../home.ts

Error: The Angular AoT build failed. See the issues above

What is wrong? How can I fix that?

BrunoLM
  • 97,872
  • 84
  • 296
  • 452
  • One of the issues is that I cannot use `const foo = () =>`, using a `function` export works. But there is still another issue, I cannot use `typeof`, why? Is there any way around that? – BrunoLM Feb 19 '18 at 22:11
  • 1
    Are you aware that the Hostlistener decorator is cabable of handling mobile events and event translate mouse events into touch events ? You just need to import 'hammerjs. – cyr_x Feb 19 '18 at 23:43
  • @cyrix can you explain how exactly hammerjs would solve this issue? – BrunoLM Feb 21 '18 at 16:20

2 Answers2

16

This means exactly what the error says. Function calls aren't supported in a place where you're doing them. The extension of the behaviour of Angular built-in decorators isn't supported.

AOT compilation (triggered by --prod option) allows to statically analyze existing code and replace some pieces with expected results of their evaluation. Dynamic behaviour in these places means that AOT cannot be used for the application, which is a major drawback for the application.

If you need custom behaviour, HostListener shouldn't be used. Since it basically sets up a listener on the element, this should be done manually with renderer provider, which is preferable Angular abstraction over DOM.

This can be solved with custom decorator:

interface IMobileAwareDirective {
  injector: Injector;
  ngOnInit?: Function;
  ngOnDestroy?: Function;
}

export function MobileAwareListener(eventName) {
  return (classProto: IMobileAwareDirective, prop, decorator) => {
    if (!classProto['_maPatched']) {
      classProto['_maPatched'] = true;
      classProto['_maEventsMap'] = [...(classProto['_maEventsMap'] || [])];

      const ngOnInitUnpatched = classProto.ngOnInit;
      classProto.ngOnInit = function(this: IMobileAwareDirective) {
        const renderer2 = this.injector.get(Renderer2);
        const elementRef = this.injector.get(ElementRef);
        const eventNameRegex = /^(?:(window|document|body):|)(.+)/;

        for (const { eventName, listener } of classProto['_maEventsMap']) {
          // parse targets
          const [, eventTarget, eventTargetedName] = eventName.match(eventNameRegex);
          const unlisten = renderer2.listen(
            eventTarget || elementRef.nativeElement,
            eventTargetedName,
            listener.bind(this)
          );
          // save unlisten callbacks for ngOnDestroy
          // ...
        }

        if (ngOnInitUnpatched)
          return ngOnInitUnpatched.call(this);
      }
      // patch classProto.ngOnDestroy if it exists to remove a listener
      // ...
    }

    // eventName can be tampered here or later in patched ngOnInit
    classProto['_maEventsMap'].push({ eventName, listener:  classProto[prop] });
  }
}

And used like:

export class FooComponent {
  constructor(public injector: Injector) {}

  @MobileAwareListener('clickstart')
  bar(e) {
    console.log('bar', e);
  }

  @MobileAwareListener('body:clickstart')
  baz(e) {
    console.log('baz', e);
  }  
}

IMobileAwareDirective interface plays important role here. It forces a class to have injector property and this way has access to its injector and own dependencies (including ElementRef, which is local and obviously not available on root injector). This convention is the preferable way for decorators to interact with class instance dependencies. class ... implements IMobileAwareDirective can also be added for expressiveness.

MobileAwareListener differs from HostListener in that the latter accepts a list of argument names (including magical $event), while the former just accepts event object and is bound to class instance. This can be changed when needed.

Here is a demo.

There are several concerns that should be addressed additionally here. Event listeners should be removed in ngOnDestroy. There may be potential problems with class inheritance, this needs to be additionally tested.

Estus Flask
  • 206,104
  • 70
  • 425
  • 565
  • It seems this approach doesn't work with inheritance (decorators on the parent class are not executed). Is there a way to do that? (Could it be because I'm creating components dynamically?) Should I just ask a new question? – BrunoLM Feb 21 '18 at 00:44
  • If you change `if (!classProto['_maPatched']) {` to also check for `hasOwnProperty` then it will work with inheritance. – BrunoLM Feb 22 '18 at 14:25
  • 1
    Yes, but there's more than that. classProto['_maEventsMap'] can be prototypically inherited, too, and it certainly shouldn't be modificated on class prototype. Another thing is that ngOnInit can be already patched by parent constructor, and patching it in child too may set up some listeners twice. All these concerns should be taken into account. – Estus Flask Feb 22 '18 at 14:31
1

A full implementation of estus answer. This works with inheritance. The only downside is that still requires the component to include injector in the constructor.

Full code on StackBlitz

import { ElementRef, Injector, Renderer2 } from '@angular/core';

function normalizeEventName(eventName: string) {
  return typeof document.ontouchstart !== 'undefined'
    ? eventName
        .replace('clickstart', 'touchstart')
        .replace('clickmove', 'touchmove')
        .replace('clickend', 'touchend')
    : eventName
        .replace('clickstart', 'mousedown')
        .replace('clickmove', 'mousemove')
        .replace('clickend', 'mouseup');
}


interface MobileAwareEventComponent {
  _macSubscribedEvents?: any[];
  injector: Injector;
  ngOnDestroy?: () => void;
  ngOnInit?: () => void;
}

export function MobileAwareHostListener(eventName: string) {
  return (classProto: MobileAwareEventComponent, prop: string) => {
    classProto._macSubscribedEvents = [];

    const ngOnInitUnmodified = classProto.ngOnInit;
    classProto.ngOnInit = function(this: MobileAwareEventComponent) {
      if (ngOnInitUnmodified) {
        ngOnInitUnmodified.call(this);
      }

      const renderer = this.injector.get(Renderer2) as Renderer2;
      const elementRef = this.injector.get(ElementRef) as ElementRef;

      const eventNameRegex = /^(?:(window|document|body):|)(.+)/;
      const [, eventTarget, eventTargetedName] = eventName.match(eventNameRegex);

      const unlisten = renderer.listen(
        eventTarget || elementRef.nativeElement,
        normalizeEventName(eventTargetedName),
        classProto[prop].bind(this),
      );

      classProto._macSubscribedEvents.push(unlisten);
    };

    const ngOnDestroyUnmodified = classProto.ngOnDestroy;
    classProto.ngOnDestroy = function(this: MobileAwareEventComponent) {
      if (ngOnDestroyUnmodified) {
        ngOnDestroyUnmodified.call(this);
      }

      classProto._macSubscribedEvents.forEach((unlisten) => unlisten());
    };
  };
}
BrunoLM
  • 97,872
  • 84
  • 296
  • 452