2

First and foremost, I'm well aware of zone.runOutsideAngular(callback), documented here .

What this method does is running the callback in a different zone than the Angular one.

I need to attach a very quick callback to document's mousemove event (to count Idle time). I don't want to have the whole Zone.JS machinery of tasks appended to execution queues for this specific callback. I would really like to have the callbacks run in plain, unpatched browser runtime.

The event registration code I have is:

  private registerIdleCallback(callback: (idleFromSeconds: number) => void) {
    let idleFromSeconds = 0;
    const resetCounter = function() {
      idleFromSeconds = -1;
    };
    document.addEventListener('mousemove', resetCounter);
    document.addEventListener('keypress', resetCounter);
    document.addEventListener('touchstart', resetCounter);
    window.setInterval(function() {
      callback(++idleFromSeconds);
    }, 1000);
  }

The question is how can I get this code to use the unpatched document.addEventListener, accomplishing a complete separation from Zone.JS and true native performance?

Alberto Chiesa
  • 7,022
  • 2
  • 26
  • 53

2 Answers2

3

Well, it turned out to be simple, but not straightforward, so I'm going to post the solution I found here.

What I want to do is saving a usable document.addEventListener in a global object before Zone.JS patches the hell out of the browser's native objects.

This must be done before the loading of polyfills, because it's in there that Zone is loaded. And, it must not be done in plain code in polyfills.ts, because the import statements are processed before any other code.

So I need a file zone-config.ts in the same folder of polyfills.ts. In the latter an extra import is needed:

import './zone-config'; // <- adding this line to the file, before...
// Zone JS is required by default for Angular itself.
import 'zone.js/dist/zone'; // Included with Angular CLI.

In zone-config.ts I do the sleight-of-hand:

(function(global) {
  // Save native handlers in the window.unpatched object.
  if (global.unpatched) return;

  if (global.Zone) {
    throw Error('Zone already running: cannot access native listeners');
  }

  global.unpatched = {
    windowAddEventListener: window.addEventListener.bind(window),
    windowRemoveEventListener: window.removeEventListener.bind(window),
    documentAddEventListener: document.addEventListener.bind(document),
    documentRemoveEventListener: document.removeEventListener.bind(document)
  };

  // Disable Zone.JS patching of WebSocket -- UNRELATED TO THIS QUESTION
  const propsArray = global.__Zone_ignore_on_properties || (global.__Zone_ignore_on_properties = []);
  propsArray.push({ target: WebSocket.prototype, ignoreProperties: ['close', 'error', 'open', 'message'] });

  // disable addEventListener
  const evtsArray = global.__zone_symbol__BLACK_LISTED_EVENTS || (global.__zone_symbol__BLACK_LISTED_EVENTS = []);
  evtsArray.push('message');
})(<any>window);

Now I have a window.unpatched object available, that allows me to opt out of Zone.JS completely, for very specific tasks, like the IdleService I was working on:

import { Injectable, NgZone } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { filter } from 'rxjs/operators';

/* This is the object initialized in zone-config.ts */
const unpatched: {
  windowAddEventListener: typeof window.addEventListener;
  windowRemoveEventListener: typeof window.removeEventListener;
  documentAddEventListener: typeof document.addEventListener;
  documentRemoveEventListener: typeof document.removeEventListener;
} = window['unpatched'];

/** Manages event handlers used to detect User activity on the page,
 * either via mouse or keyboard events. */
@Injectable({
  providedIn: 'root'
})
export class IdleService {
  private _idleForSecond$ = new BehaviorSubject<number>(0);

  constructor(zone: NgZone) {
    const timerCallback = (idleFromSeconds: number): void => this._idleForSecond$.next(idleFromSeconds);
    this.registerIdleCallback(timerCallback);
  }

  private registerIdleCallback(callback: (idleFromSeconds: number) => void) {
    let idleFromSeconds = 0;
    const resetCounter = () => (idleFromSeconds = -1);

    // runs entirely out of Zone.JS
    unpatched.documentAddEventListener('mousemove', resetCounter);
    unpatched.documentAddEventListener('keypress', resetCounter);
    unpatched.documentAddEventListener('touchstart', resetCounter);

    // runs in the Zone normally
    window.setInterval(() => callback(++idleFromSeconds), 1000);
  }

  /** Returns an observable that emits when the user's inactivity time
   * surpasses a certain threshold.
   *
   * @param seconds The minimum inactivity time in seconds.
   */
  byAtLeast(seconds: number): Observable<number> {
    return this._idleForSecond$.pipe(filter(idleTime => idleTime >= seconds));
  }

  /** Returns an observable that emits every second the number of seconds since the
   * ladt user's activity.
   */
  by(): Observable<number> {
    return this._idleForSecond$.asObservable();
  }
}

Now the stackTrace in the (idleFromSeconds = -1) callback is empty as desired.

Hope this can be of help to someone else: it's not complicated, but having all the bits in place was a bit of a chore.

Alberto Chiesa
  • 7,022
  • 2
  • 26
  • 53
  • Is there a way to use this to make sure calls from 3rd party libraries are unpatched. I have calls to web sockets and web workers coming from 3rd parties that are getting patched. – Gilles Jul 14 '21 at 17:40
  • 1
    Not using _exactly_ the same code, but you can use the part using the `__zone_symbol__BLACK_LISTED_EVENTS` global object, and fill it taking inspiration from my example and from the (somewhat sparse) documentation at https://github.com/angular/angular/blob/master/packages/zone.js/STANDARD-APIS.md. If you fill the `__zone_symbol__BLACK_LISTED_EVENTS` object before zone.js patches the page, everyone calling into WebSockets or other API will use the native version. Just remember that, outside of Zone, you are in charge of getting back into Angular-land (via NgZone or ChangeDetectorRef). – Alberto Chiesa Jul 15 '21 at 14:55
1

I am not sure if I got what you want to do. I have made a little plunker that shows how to do an event callback from outside of Angular.

https://stackblitz.com/edit/angular-s3krnu

Ludwig
  • 1,242
  • 10
  • 8
  • Thanks! I can't seem to find where do you declare the `registerEventCallback` function. How is it referenced? Maybe you could add to the answer how it is implemented. – Alberto Chiesa Oct 24 '19 at 10:41
  • Have a look at the index.html also. – Ludwig Oct 24 '19 at 10:44
  • No, that will not work, because when the registerEventCallback will be invoked, it will run against a _patched_ `document` object. I'm currently trying to save the unpatched functions into a separate object, for later use. – Alberto Chiesa Oct 24 '19 at 10:46
  • I perfected the approach and came up with a working solution. Thanks for the input anyway! – Alberto Chiesa Oct 28 '19 at 08:09