8

I'm creating an Angular 2 component, and Angular's change detection isn't working for me when using a certain Observable pattern. It looks something like this:

    let getResult$ = this.http.get('/api/identity-settings');

    let manager$ = getResult$
        .map((response) => /* -- create manager object -- */);

    let signinResponse$ = manager$
        .flatMap(manager => manager.processSigninResponse());

    this.readyToLogin$ = manager$.map(() => true).startWith(false);
    this.isLoggedIn$ = signinResponse$.map(() => true).startWith(false);

Then in my template:

<h1>Ready to Log In: {{readyToLogin$ | async}}</h1>
<h1>Logged In: {{isLoggedIn$ | async}}</h1>

Since the readyToLogin$ Observable is based on a synchronous set of operations that happen in response to the http.get() (which Angular "monkey patches" to ensure it knows when it needs to detect changes), the "Ready to Log In" message switches to true at the appropriate time.

However, since processSignInResponse() produces a Promise<>, anything subscribing to the result of flatMap is occurring asynchronously to the http request's completion event. Therefore, it requires manual intervention to notify my component's zone that it needs to check for changes.

How can I wrap the signInResponse$ observable in a way that NgZone knows to detect changes after any subscriptions have been resolved?

Update

Brandon's answer worked until I updated to RC5, at which point things stopped working again. Turns out the 3rd-party library I was using borked Zone.js. Once that was resolved, there was no need to use a workaround at all--the built-in monkey patching just worked!

StriplingWarrior
  • 151,543
  • 27
  • 246
  • 315
  • `Promise` normally doesn't cause issues with NgZone. I have seen it mentioned in issues that `Promise` when imported from the wrong library can cause such issues (I'm using Dart and don't know details about polyfills and stuff of TS/JS). Also if code that emits observable events somehow runs outside Angulars zone, then change detection would break this way. – Günter Zöchbauer Jun 02 '16 at 05:11
  • @GünterZöchbauer: As I understand it, usually `Promise`s are resolved by some event that has already been "monkey patched" to work with Zone.js. For example, an angular HTTP request or a DOM event handler fires, causes a promise to be resolved, and in the synchronous process of resolving that promise any actions that occur are happening in the context of a zone running. But this library that I'm using must resolve its promise after some kind of event that hasn't been monkey-patched. – StriplingWarrior Jun 02 '16 at 14:52
  • Yes, something like that. – Günter Zöchbauer Jun 02 '16 at 14:54
  • @StriplingWarrior What do you mean on your edit ? The built in behavior of angular just worked ? How could angular be aware ? doesn't make sens – Ced Feb 22 '17 at 09:39
  • @Ced: Angular depends on Zone.js, which uses polyfills to detect when browser events could cause a change to happen. When I imported a library in a way that overwrote those polyfills, Zone could not detect when one of these events occurred. When I fixed that problem, the AJAX response correctly got detected by Zone, which told Angular something had happened. – StriplingWarrior Feb 22 '17 at 20:17
  • @@StriplingWarrior, is the problem still exists? can you post a reproduce plunker or repo? – jiali passion Mar 12 '17 at 13:26
  • @jialipassion: No, when I tried creating a plunker, the problem wasn't there. That's how I was able to recognize that the problem came from another library. I was including a library distributable that had another set of polyfills included in it, and those polyfills were overwriting the polyfills that zone.js was using. (Remember, Zone.js needs to be included *after* the polyfill library.) – StriplingWarrior Mar 16 '17 at 03:45
  • @StriplingWarrior, yes, if other library also monkey-patch global functions, then zone.js should be loaded after those libraries, I will wait for your plunker. – jiali passion Mar 16 '17 at 05:38
  • @jialipassion: I don't think you understand. I am not working on a plunker. I ended up solving my own problem by changing the way I was loading a third-party library. There was nothing wrong with Angular 2 in the first place: only with the way I was loading my other libraries. – StriplingWarrior Mar 16 '17 at 16:58
  • @StriplingWarrior, ok, I see, the other libraries also patch global function and loaded after zone.js cause problem, is that right? – jiali passion Mar 17 '17 at 02:41
  • @jialipassion: That's correct. – StriplingWarrior Mar 17 '17 at 03:17

3 Answers3

9

For RxJs 6 with the pipable operator:

private runInZone(zone) {
  return function mySimpleOperatorImplementation(source) {
    return Observable.create(observer => {
      const onNext = (value) => zone.run(() => observer.next(value));
      const onError = (e) => zone.run(() => observer.error(e));
      const onComplete = () => zone.run(() => observer.complete());
      return source.subscribe(onNext, onError, onComplete);
    });
  };
}

Usage:

$someObservable.pipe(runInZone(zone));
Robin Dijkhof
  • 18,665
  • 11
  • 65
  • 116
  • Very nice. Just export this function and use it like a normal pipe operator. Exactly what I was looking for. Thank you! – Lincoln Apr 05 '19 at 13:24
  • This should be the accepted answer as it is the same as the accepted answer but up to date with new rxjs api – Wilhelmina Lohan May 10 '19 at 20:12
8

You can make a new observeOnZone operator that can be used to "monkey patch" any observable. Something like:

Rx.Observable.prototype.observeOnZone = function (zone) {
    return Observable.create(observer => {
        var onNext = (value) => zone.run(() => observer.next(value));
        var onError = (e) => zone.run(() => observer.error(e));
        var onComplete = () => zone.run(() => observer.complete());
        return this.subscribe(onNext, onError, onComplete);
    });
};

And use it like so:

this.isLoggedIn$ = signinResponse$.map(() => true).startWith(false).observeOnZone(zone);
StriplingWarrior
  • 151,543
  • 27
  • 246
  • 315
Brandon
  • 38,310
  • 8
  • 82
  • 87
  • Thank you! This ended up working with some modifications. First, I fixed your `observeOnZone` method to call the observer's `.next()`, `.error()`, etc. instead of `.onNext()`, `onError()`, etc. (I edited your post to show this fix). Secondly, because in my real use case the `signInResponse$` is created in a service outside of any angular component, I needed a way to generically capture the appropriate zone at the point where it's currently being lost: `.flatMap(manager => Observable.fromPromise(manager.processSigninResponse()).observeOnZone(Zone.current))` – StriplingWarrior Jun 02 '16 at 18:04
  • 1
    Thanks for the fixes. I haven't used rxjs5 so I usually forget about the changes they made to the API. – Brandon Jun 02 '16 at 19:40
  • 1
    for new pipeable syntax see https://stackoverflow.com/a/50583597/6656422 – Wilhelmina Lohan May 10 '19 at 20:11
2

You can force code into Angulars zone using zone.run()

constructor(private zone:NgZone) {}

someMethod() {
  signinResponse$.subscribe(value => {
    zone.run(() => doSomething());
  });
}
Günter Zöchbauer
  • 623,577
  • 216
  • 2,003
  • 1,567
  • Yes, I knew this much. The problem is, I don't want to subscribe to the `signInResponse$` from my component code: I want the async-piped binding in the UI to work correctly because the observable is being resolved in the correct zone. – StriplingWarrior Jun 02 '16 at 17:59
  • Just wondering : if `signinResponse` was an HTTP request - would still `zone.run` be needed ? – Royi Namir Dec 30 '17 at 21:14
  • @RoyiNamir that onlybdepends on whether the code is run inside the zone or not. Normally you don't need zone.run at all, but if somehow code runs outside Angulars zone, you need to bring it back in to make change detection work. (`ChangeDetectorRef.detectChanges` might do as well, depending on the concrete situation) – Günter Zöchbauer Dec 30 '17 at 22:47