Zones in Dart: Big Brother Is Watching You

Dmitry Elagin
Wrike TechClub
Published in
10 min readOct 21, 2020

Hello! My name is Dima, and I’m a frontend developer at Wrike. We write the client part of the project in Dart, and we often deal with asynchronous operations. Zone is one of the most useful tools that Dart provides for this purpose. I’ve recently started to dig into this topic, and today I’ll show you examples of using zones and the non-obvious features of their behaviour. Let’s take a look at AngularDart.

For a basic introduction to zones, read my first article.

NgZone and optimization of the change detection process

Let’s imagine a situation: During the sprint review, you’re talking about a new feature, confidently avoiding known bugs, and showing the functionality at its best. But after a couple of clicks, the framework’s self-written performance counter shows over 9,000 attempts to re-render the interface. And all of that in just a few seconds!

After the review is over, you feel an irresistible urge to fix the situation. Your first idea is most likely to cheat the counter. To do so, you need to figure out how it works. Let’s look inside the code. We’ll probably see these lines:

After further research, you can see the reason: Angular uses the onMicrotaskEmpty stream at the root of each application in order to automatically launch the change detection process for every event:

It seems like we need to figure out what NgZone is and how it works to fix our app properly. Let’s look under the hood.

NgZone isn’t a zone, but a wrapper over two other zones: the external one (where the Angular app started) and the internal one (created by Angular and inside where it automatically starts all app operations). Both are saved at the NgZone creation stage:

The inner zone takes on a lot of work and uses different features of the zones.

First, let me briefly remind you why change detection is needed.

This is what a simple tree structure of components may look like

To build an interface, Angular uses components arranged in a tree structure. Normally, as the application starts, components get filled with data, build a DOM tree, and wait for and save changes to their data. During all of this, Angular doesn’t know about these changes immediately. At convenient moments it starts the process of traversing the component tree from the root through all potentially affected nodes in search of changed data — change detection. If changes have taken place, the framework starts updating the corresponding DOM subtree.

There are different sources for these changes — user events, online notifications, or timeouts. Any of them can affect the interface, which means you need to check if something has changed in these components and update the interface. Hence, our first goal is to track all possible events.

A lot of events can happen in a short period of time. If Angular tries to track changes after each event, the app will drop drastically in performance. Moreover, event responses can be instantaneous for the user, but asynchronous for the execution thread.

Let’s remember the browser’s event loop:

I took this figure from a great talk about event loops by Jake Archibald.

On the left we see a section where a task will be executed. On the right there’s a section where scripts scheduled by requestAnimationFrame will be executed in turn, then style calculation, then layout calculation, and then rendering. We can only execute script tasks in the yellow sections.

Now let’s try to get inside the heads of the authors of Angular and understand when it’s best to execute detectChanges.

The easiest way to do so is after the task is completed, but our task can schedule microtasks, which, in turn, can change the data. This will lead to either inconsistency or another detectChanges launch. No good.

We could try tracking changes within the requestAnimationFrame, but here I can give you a whole set of obstructions:

  • Change detection on a large application can take quite a long time. This can cause too many frames to omit.
  • Scripts running in requestAnimationFrame can also schedule microtasks that will run immediately after the script is executed and before rendering. We’ve already discussed the consequences.
  • The interface after change detection may not be completely stable. There’s a risk that the user will see an unplanned animation of the interface change instead of the expected result.

Another option is to run detectChanges after the task and all the microtasks, if any, are executed. This is our second goal.

It turns out that to work Angular’s “magic,” it’d be nice to:

  • Catch all possible user events.
  • Run change detection after the task and all the microtasks planned at that time we’ve finished executing.

Catch all possible user events. This can be perfectly handled by the same innerZone.

Let’s have a look again:

In the previous article we already discussed that Future executes its callback in the zone where it was created. Since Angular tries to create and execute everything in its internal zone at the start, after the Future completes, the task is performed using the _run handler.

Here’s what it looks like:

Using the run* family of methods, we catch all user events, because after the application starts, changes in it are likely to be caused by asynchronous interactions. Before executing a callback, NgZone remembers that changes may be taking place in the app now, and counts the nesting of the callbacks. After executing the callback, the zone calls _checkStable method without scheduling it for the next iteration of the event loop.

Run change detection after the scripts in the stack and all the microtasks planned at that time have finished executing. The second important element of the inner zone is scheduleMicrotask:

This function monitors when all microtasks are finished. The work is similar to ”run”: We count how many microtasks have been scheduled and how many have already been completed. There can be a lot of microtasks scheduled at once, and all of them will be executed before the next task is launched. The zone calls _checkStable within the last microtask, without planning another one.

Finally, let’s look at the method that ends everything:

That’s when we get to the point! This method checks if there are more nesting or unfinished microtasks. If everything is complete, it dispatches an event via _onMicrotaskEmpty. This is the stream that synchronously launches detectChanges! Additionally, at the end we check if any new microtasks were created when change detection was running. If all goes well, NgZone considers the view stable and reports that the run has ended.

To summarize:

Angular tries to do everything in NgZone. Each Future when completed, each Stream at every event, and each Timer at the end of time will launch run* or scheduleMicrotask, which also means detectChanges.

Let’s remember that this isn’t over. For example, addEventListener on the Element object from dart:html will also tell the current zone about the scheduled work, even though it isn’t a Stream, Timer, or Future. Another example is calling _zone.run() will also launch detectChanges, because we use NgZone directly.

This process is optimized. The detectChanges method will only run once, at the very end of the task that triggered it, or within the most recent microtask scheduled in the last task. Change detection will happen not in the next iteration of the event loop, but in the current one.

In our project, we use the OnPush strategy for change detection of components. It allows us to save a lot on this operation. However, no matter how fast detectChanges is idling, events like scroll and mouseMove can trigger it very often. According to my own test, non-debounced or non-throttled calls can consume 200ms from each second for a user. It depends on many factors, but there’s definitely something to consider.

And since this deep dive into the bowels of Angular began with the urge to optimize, I’d like to end this article with a couple of curious and not self-evident conclusions from the knowledge obtained.

Stream and runOutsideAngular

The main case of runOutsideAngular refers to the situation when we listen to a very fast stream that we also want to filter, i.e., onMouseMove for the Element object. A quick look under the stream’s hood won’t work, because there are many stream implementations in Dart. But this article about zones has a simple and effective rule:

Transformations and other callbacks are executed in the zone that started to listen to the stream.

The zone depends on the subscription. It’s executed where it’s created. Therefore, it’s recommended to subscribe and filter fast streams outside of the Angular zone:

What isn’t obvious here is why we put the stream outside the Angular zone if it’s filtered anyway. Wouldn’t it be more concise without it?

The problem is that we don’t make a single subscription here. The where method returns a stream when called. And it isn’t the same stream. It’s a new _WhereStream:

When we subscribe to _WhereStream, it immediately subscribes to the parent stream, and so on to the source. And all these subscriptions will be created in the current zone, which means detectChanges will be triggered as many times as the fastest stream in the chain is triggered — even if we created the entire chain in a different zone.

Zone control for package:redux_epics

We often use the redux_epics package in our views. Under the hood it uses streams very actively and forces us to use them as well. Sometimes actions that we dispatch may not affect our condition. Besides, our change detection will start in any case after the action works and changes something, so we don’t need to kick it over again. To avoid false positives, we’ll execute epics outside of the Angular zone. How do we do that?

Since all the stream actions are executed in the zone where we subscribed to it, we should look for the listen method in the redux_epics code:

We will find it in the call method. This means that the subscription is created at the time of the middleware call (in this case, the first call), and this happens when the action is dispatched.

Hence, a simple conclusion: The first action must be dispatched outside the Angular zone, i.e., in the root component after creating the store:

And if there’s nothing to dispatch, then null will do:

After that, epics streams will be performed outside the Angular zone, which will eliminate some of the parasitic launches of change detection.

Multiple change detection for native events

Now this is a neat trick. Let’s say we have a parent component, it has a child component, and the child has a button element:

In each of these components, we listen to the native click event. It bubbles to the parent. The trick is that change detection will run twice here. During the template compilation event, listeners are compiled not as a stream, but as a close to native addEventListener:

_el_0.addEventListener(‘click’, eventHandler(_handleClick_0));

This will happen in both components. This means that we also bring here an interesting feature of addEventListener: When the user clicks the button, the browser will create a special task, which will generate as many task executions within a single iteration of the event loop, as listeners will be affected by the event bubbling. And after each script, all the microtasks generated by it will be executed immediately, and detectChanges will also be executed.

So in Angular it’s better not to count on the event bubbling, but to create an Output in the child component:

This option will launch change detection once, because Output is a stream, and even asynchronous streams use microtasks, which, as we already know, NgZone tracks well.

This strange behavior of pop-up events is well described in this article about microtasks by the same Jake Archibald.

Rocks ahead!

Zone is a powerful tool that solves special tasks and often simplifies the interface. But none of the examples shown previously is written in our own project. They are all from third-party libraries.

Explicit is better than implicit. The app code should be simple, readable, and understandable. Zone is a tool that looks like magic, which is acceptable in community-tested libraries or in self-developed and well-tested utilities. But we need to be careful with implementing such a tool into the code that we work with on a daily basis.

I’d like to end with a little warning. Zone is not too well-documented of a functionality, and sometimes you can encounter bugs that can’t be easily fixed. Here, for example, is an issue that we found. Let me briefly describe it to you.

When a Future is created, it keeps the current zone, which gives us some control. But it turns out that the Dart SDK has at least two pre-created Futures with the root zone saved in them:

Let me remind you that any Future must execute callbacks scheduled in a microtask. If we try to add a task to Future using the then method, it’ll at least execute:

  • zone.scheduleMicrotask
  • zone.registerUnaryCallback
  • zone.runUnary

We already know the callback is guaranteed to be registered and executed in the zone where it was passed to the then method. But with scheduleMicrotask it’s not that simple.

The Future has an optimization: If several callbacks are hung on one Future, it’ll try to execute them all in one microtask:

Both callbacks in this example will only have one call to scheduleMicrotask. Great. But sometimes callbacks are hung in different zones:

In this case they’ll still be executed in a single microtask. Here’s a tricky question: Which zone should schedule this microtask? The first? Or the second? The developers of Dart decided that it’ll always be the zone recorded in the original Future:

This means if we schedule a callback execution for a previously created and compiled _nullFuture, scheduleMicrotask will be called from the root zone instead of the current zone:

The current zone will never know a microtask was scheduled. This behavior can easily break the previously discussed FakeAsync: It won’t be able to execute synchronously what it doesn’t know.

You might think _nullFuture will never come out, but:

It isn’t so difficult to get it — and from a completely unexpected place. That’s where the bugs with FakeAsync are from.

We could use some help with discussing this strange behavior. Welcome to the issue — together we stand, divided we fall! Plus, there’s additional information from contributors about how other zones interact with Future and Stream, don’t miss it!

That’s it. If you have any questions, leave them in the comment section below, and I’d be more than happy to answer them!

--

--