About those Side-effects in Observables, an Angular Use Case

When testing a codebase in Angular Ivy, I ran into a bunch of test failures I wasn’t seeing before. ExpressionChangedAfterItHasBeenCheckedErrors were being thrown around. In debugging these failures, I found that many of them are the results of side-effects in Observables and Observable pipe operations. I happened to describe these earlier in my piece on Observables, Side-effects, and Subscriptions.

Consider this minimal reproduction:

@Component({
  selector: 'widget-editor',
  templateUrl: 'widget_editor.html'
})
export class WidgetEditor {
  constructor(private readonly service: WidgetService) {}

  widgetName = '';
  widgetConfig$ = this.service.getWidget('my_widget').pipe(
    map(widgetDetails => {
      this.widgetName = widgetDetails.name;
      return widgetDetails.config;
    })
  );
}

There’s enough code smell from looking at this alone: an unsuspecting user subscribing to widgetConfig$ will result in member variables of WidgetEditor getting mutated. That’s strange behavior, but with the right Angular template, it can also become an outright bug:

<h1>Editing {{widgetName}}...</h1>
<ng-container *ngIf="(widgetConfig$ | async) as widget; else loading">
  <h2>Widget Config<h2>
  <widget-config-view [widget]="widget"></widget-config-view>
</ng-container>
<ng-template #loading>Loading...</ng-template>

Testing this code might give you an error that looks something like:

Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: ''. Current value: 'my_widget_name'.

Why?

The normal case

At render time, widgetConfig$ has never been subscribed to. Therefore, no HTTP request has been made (Observables are lazy that way), and the map operator therefore hasn’t been called. The value of widgetName is ''. When Ivy tries to render the <ng-container>, the Async Pipe triggers subscribing to widgetConfig$, and, eventually, all the side-effects that come with it’s operators.

When widgetConfig$ is subscribed to, using the Async Pipe, it kicks off the Observable (and, eventually, all of it’s side-effects).

In most cases, we get the widget result asynchronously. Side-effects happen at some point, and change detection forces a re-render cycle that picks up the new widgetName and widgetConfig$ results.

The error case

What if a test injected a FakeWidgetService instead, whose getWidget call used the of RxJS operator to immediately return a value? The default scheduler used in the of operator immediately returns the value within the operator when subscribed. This means that subsequent pipes are executed immediately, and, in our case, the side-effects run immediately.

Consider that use case and follow along with the initial render: widgetName is initially rendered the first time as the empty string ''. Later, when widgetConfig$ is subscribed to by the Async Pipe, it immediately changes the value of widgetName. By the time rendering is complete, Angular notices that the value of widgetName it just rendered is different than the current value.

Can’t we just change the test scheduler?

We could, and that would fix this problem. But then you’re sort of leaking abstractions; asking services you depend on to have Observables with schedulers that behave a certain way, and depending on more than just the implied contract of the interfaces you use.

The Fix

@Component({
  selector: 'widget-editor',
  templateUrl: 'widget_editor.html'
})
export class WidgetEditor {
  constructor(private readonly service: WidgetService) {}

  private readonly widget$ = this.service.getWidget('my_widget').pipe(
    publishReplay(1), refCount()
  );

  widgetName$ = this.widget$.pipe(
    map(widgetDetails => widgetDetails.name)
  );

  widgetConfig$ = this.widget$.pipe(
    map(widgetDetails => widgetDetails.config)
  );
}

A common reason for side-effects (as is with this case) is to reuse an intermediate value within a sequence of transformations elsewhere. Making that explicit, by exposing the intermediate piece as a private Observable itself, will clean things up quite a bit.

To make sure that a subscription to widgetName$ and widgetConfig$ wouldn’t result in two separate HTTP requests (or underlying side-effects, whatever those may be), making sue the widget$ Observable is shared between the two will be important.

Leave a comment

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: