The notorious ExpressionChangedAfterItHasBeenCheckedError in Angular and Rxjs
This article is about the famous ExpressionChangedAfterItHasBeenCheckedError
in Angular. I have seen this error few times in my work and it's painful to debug and fix it. Today I spend some time to investigate it and finally find out why and how this happened. And most importantly, how to properly fix it.
History between me and change after check error
As I said before, I have seen this error few times. It is kind a nightmare for me to handle this. Every time I saw it, I blindly force trigger Angular's change detection. Sounds easy, however, I see this error in the unit test most of the time. This make it even harder to debug it because when you won't see it in working application. But it just keep failing in the unit test. For example, the code below fails in the unit test and emit changed after check error.
<div *ngIf="someObservable | async as data">
...some code using data
</div>
This code looks good. I utilize the async pipe of Angular and it works fine in the actual application. Nothing seems wrong but it just failed in my unit test. I really want to know why and finally I got some time to dig this problem.
First clue: change detection
If you have worked in Angular for a while, you may find it obviously mean the change happened outside the digest cycle. So there is definitely something wrong with the change detection. Let's quickly go through how Angular implement its change detection mechanism. Or you can read more in this article written by Angular team Angular Change Detection - How Does It Really Work?.
TL;DR
Angular overwrite or patch support to these frequently used browser mechanisms like click event, setTimeout(), Ajax HTTP request, etc. Or you can say Angular put a spy on it. Whenever these events fire, notify Angular something changed.
Second clue: setTimeout() and Rxjs
Why setTimeout()? Because setTimeout() can fix the evil change after check error. So there might be connection between Rxjs and setTimeout(). It might have connection to other stuff as well, but my current focus is why my Rxjs code fail in unit test but not in working application.
If you ever wonder how Rxjs manage observables, you can read more in Rxjs's document about scheduler.
TL;DR
Rxjs maintains a queue of tasks to handle its subscription. Then what does Rxjs do with the task in the queue?
It denotes where and when the task is executed (e.g. immediately, or in another callback mechanism such as setTimeout or process.nextTick, or the animation frame).
Why? because browser is a single threaded environment. I believe for the performance purpose, Rxjs need to prioritize all its subscription and execute them.
Boom! So now we see the connection between Angular change detection and the Rxjs scheduler. Rxjs use mechanism like setTimeout to determine to execute tasks in the queue. Then Angular listen to these setTimeout event to trigger view re-render.
Why it failed in unit test?
Now let's dig deeper about why my unit test fails. The problem come from the mock call. Let's recall my code first.
<div *ngIf="someObservable | async as data">
...some code using data
</div>
The someObservable
comes from mock call in my unit test where I just use of()
.
const someObservable = apiCall.pipe(map(dataTrasformFunction()));
And in my unit test, I directly mock the apiCall
.
// Somewhere in the unit test
const apiCall = of(data);
And from the Rxjs scheduler document (yes, you better read that document), it automatically use the least concurrency operator. In this case, Rxjs make it synchronize and won't put it into its scheduler queue which won't trigger any setTimeout
mechanism and so it won't trigger Angular change detection.
How to fix it?
We are not doing anything wrong but just in production code, the apiCall
is usually a infinite sequence of events until it is completed. But in the unit test, Rxjs know it is a finite events and so won't put it into scheduler queue.
That is why you add a setTimeout(0)
or delay(0)
in the pipe will help the situation. However, I believe the better approach is to tell Rxjs to put the task into queue just like it work in the working application. And this is how you do it.
// Somewhere in the unit test
import {of, asapScheduler} from 'rxjs';
const apiCall = of(data, asapScheduler);
// or
const apiCall = testObservableYouCantControl.pipe(observeOn(asapScheduler));
And now I feel relief, I finally solve one of the mystery that always bugs. And I hope this article help you as well :)