Hello! My name is Dima, 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 and powerful tools that Dart provides for this purpose. I decided to dig into it myself and share my knowledge with you.
Disclaimer: All the code used in this article only pretends to be a copy paste. In fact, I’ve greatly simplified it and got rid of the irrelevant details. For this article, I used Dart version 2.7.2 and AngularDart version 5.0.0.
I hadn’t heard much about zones before Dart. They aren’t typically used directly and have quite specific features. As the
dart:async import hints to us, they make sense particularly for asynchronous operations.
In Wrike (as well as any other project on AngularDart), you can usually find zones in pieces of code like this:
Advanced Dart developers call it ancestral knowledge — just do what others did before you and it’ll work.
However, if you look under the hood of some libraries, you’ll find that zones are often used to:
- Simplify the API in utilities that use asynchronous operations.
- Fix some drops in app performance.
- Catch unhandled exceptions and fix bugs (which are sometimes caused by zones but are irrelevant now).
There are a few resources about zones in the Dart community, except for the issues on GitHub. This is the API documentation, an article from the official site of the language, which explains the case of catching asynchronous errors, and a few talks such as the DartUP conference. Therefore, as the most complete guide I suggest the code itself.
In this article I’ll show you code examples from the libraries that we use in the project:
Let’s start with the basics.
The Intl package is part of our localization engine. The principle of operation is quite simple: When the application starts, we look for a locale selected by default, load it, and each time we call the message or plural methods, we return the required text for the passed key.
Keys are named this way:
Sometimes we need to change the locale temporarily, i.e., a user enters a time period and we need to parse it from the current language. If we can’t do it from the current one, then we’ll try a fallback language. We use the
withLocale method provided for this purpose:
Here we’re trying to parse user input first in the default locale, and then in the fallback locale.
It looks like
withLocale changes the current language to the specified one, executes the passed callback, and then returns everything as it was.
parseText method returns with Future, because the necessary locale may not be loaded yet, which means we need to wait for the result. While we’re waiting, the user touches something in the interface and it starts to re-render. Since the current locale at this moment is Russian, the interface also changed the language to Russian. And when the operation is over, it’s back to English. No good.
What we want here is Future itself to report that the asynchronous operation has ended, then to pass on control and the result. In that case, the algorithm could switch the locale at the right time.
The good news is it’s possible! We’ll call ab asynchronous function and get a Future instance at the output:
When creating an instance, Future remembers the current zone. Now we’ll need to plan the processing of the result by using the then method:
Interesting! First, we’ll tell the current zone which callback will be executed in the future. Then we’ll create a new Future, schedule it to process the result of the asynchronous operation, and return it. The new Future remembers the zone where it was created —
Zone.current. After getting the result thanks to the
runUnary method, it executes a callback in the saved zone. Zone knows when the callback was added and when it should be executed, which means the execution process can be managed!
“It’s an execution context.” — Brian Ford, zone.js author
Zone is an object that can be described by the word “context.” It can carry contextual data and behavior. All entities that know about zones use the data or behavior of the current zone in one way or another. When working with Future, we’ve already seen that if it needs to execute a callback, it delegates this work to the zone using the
run* family of methods. And it already imposes final features and side effects on the execution of the callback.
The current zone is the zone currently referenced in the
Zone.current is a static getter for accessing
_current. In the code it looks like this:
This field differs from a global variable because it can’t be easily changed. To do this, you need a set of
run* methods the zone instance has:
runBinary. In the simplest case, these methods temporarily write their zone in the
Before executing the callback, a new zone is written in the
_current field, and the old zone is returned after execution. To execute inside the zone means replacing the zone in the
Zone.current field with the one selected for the duration of the function.
That’s it! You can even say that if the current field had a setter, these two code pieces would act similarly:
An important difference is that
run* methods guarantee the current zone will not only switch to the necessary one, but will also return to the previous one when the callback is finished. The switch will be only temporary. Instead, the developer may forget about switching or simply consider it unnecessary.
Now you see that the Intl service should be fine if it uses zones under the hood. They’ll help control the switching of the locale.
Let’s go back to the service and look at the
That’s something new! Let’s start at the beginning.
runZoned function executes a callback inside the zone. But we don’t provide an instance of the existing zone, because
runZoned creates a new zone and immediately executes a callback in it. And the
run* methods execute a callback in an already created zone.
The second interesting detail here is the
zoneValues map — powerful tool that allows you to save data in the zone. You can put any type of data in
zoneValues with a unique key (our piece of code uses Symbol).
Now let’s see where the data is read:
Voila! First, we’ll check if the locale ID is in the zone. Next, we’ll return either the locale ID or the default locale. If a locale is recorded in the zone, it’ll be used.
Data in the zone is read using the
 operator, which searches for the value in the zone and its parents (we’ll discuss them later). But the
= operator is undefined, which means that the data assigned to the zone during creation can’t be changed. This is why the
withLocale method uses
A pre-created zone wouldn’t work here, so we’re constantly creating a new zone with the current data.
Finally, let’s go back to the beginning:
Now we know the callback of the
withLocale method will be executed in the zone, and the locale we need will be written in it. Moreover, each Future created in this zone will save a link to the zone and execute its own callbacks in it. This means that the locale will switch to the specified one each time before executing
_parseText and return immediately after executing
_parseText. Just what the doctor ordered!
Now we also know how Future can interact with zones. Future, Stream, and Timer are “soaked” with such interactions with zones inside and out. Zones are aware of almost every step they take; that’s why they have such capabilities and power. We can learn more about them from the following example.
In our team, all frontend developers write unit tests. Sometimes we need to test code that works asynchronously. Dart out of the box has great testing tools, i.e., the test package that can work with asynchronous tests. When we need to wait, it’s enough to return Future from the callback of the test function, and the tests will wait for the call of the expect function until Future is completed:
In this fairytale, I’m not satisfied with one thing: waiting. What if we’re testing a stream with debounce per second or an operation has a one-hour timer? In such cases it’s worth using the mock from the outside, but this isn’t always possible.
We’re back to the problem of asynchronous tasks. But now we know we can ask zones for help. Authors of
package:quiver have already done this and have written a FakeAsync utility.
This is how it works:
We’ll create a FakeAsync object, an asynchronous operation will start with it, and all timers and microtasks will be reset. And then all the scheduled work is executed.
Let’s imagine ourselves as Penn and Teller again and see how this magic works.
Let’s take a look at the
Create your own zone, run a callback in it, and return the result. There’s nothing to it.
But from the first line we’ll meet two new details: fork and specification.
When starting the Dart app, we always have one zone already: the root. It’s so special that you can always access it via the static getter
Zone.root. Each correct zone must be a root fork, since all the basic features are implemented in the root zone. Remember this
run piece that should switch the current zone?
That was a cheat similar to the old school rule that you can’t divide by zero. In fact, the code should look more like this:
So under the guise of a normal zone, the real game changer was hidden!
All other zones are forks of the root zone. You need them to add side effects to your main work.
ZoneSpecification is the second important public interface for zones, along with
These are all possible ways to influence the behavior of the zone and, consequently, the environment. Each of the specification getters is a handler function. For each handler the first three arguments are the same, so we can use the example of a single function to explore several.
Let’s analyze a small theoretical example by calculating how many times the code will ask you to do something in the zone:
Here we create a zone with its own specification. All callbacks called using the
run method will pass through the
run handler. The handler side effect is an increment of the global counter, so we’ll calculate it.
There are some points of interest here.
When we call any method from a zone, it calls the corresponding handler from its specification under the hood. This method helps minimize the number of arguments that we pass to the handler function. It substitutes the first three.
The forked zone is a node of the tree. When we call the zone method with some arguments, they must be “passed” from the node to the root through each parent. If the chain is broken, the work we’re counting on may not be completed, because nothing will reach the root zone. This makes sense for cutting off extra work, but not in our example.
The first argument is the zone that owns the handler.
The second argument is the parent zone delegate, which is used for “passing” further. If we don’t call it, the root zone in the example won’t be able to switch the current zone to the one we need, and there’s no other way to write in the
The third argument is the zone where the call initially happened. Sometimes we need to know it because the zone that owns the handler may not be the last in the hierarchy. When this argument reaches the root, the value of the current zone in the example will change to it.
This sounds complicated, but it usually comes with practice. Another small example is illustrated by a diagram:
The first arguments for all other handlers work the same way, which means the same patterns apply to them.
Now back to FakeAsync. As we recall, in the run method the current zone is forked, and a new one is created using the specification. Let’s take a look:
First we see the
scheduleMicrotask handler. It’ll be called when something asks to schedule work in a microtask so it runs immediately after the current execution thread. For example, Future is required to process the result in a microtask, so every single Future will ask the zone to do this at least once. And not only Future — asynchronous Stream also actively uses it.
It’s an easy way FakeAsync schedules microtasks — they’re stored synchronously under the hood for later, without any questions.
Next is the
createTimer handler. You can call the
createTimer method for a zone to get a Timer object, oddly enough. You might be asking, “What about its own constructor?” Let’s take a look:
The Dart timer is just a kind of wrapper over the environment timer, but the zone wants to be able to influence the process of creating it, i.e., if you need to create a dummy timer that doesn’t count anything. This is exactly what FakeAsync does: It creates a
_FakeTimer, and the only task is to save the callback passed to it.
Unlike our example with the
run handler, the parent delegate isn’t used here because we don’t really need to schedule anything. The chain is broken, so there will be no timers or microtasks in the test environment. In fact, the point of the FakeAsync zone is to collect all callbacks for timers and microtasks that are scheduled by any asynchronous structures or actions during code execution.
All of this is done in order to then execute them synchronously in the right order! This can happen by calling the
The method iterates through the scheduled timers, synchronously calls their callbacks, and then synchronously iterates through all their scheduled microtasks. It does this until there are no tasks left. As a result, all asynchronous tasks are executed synchronously!
We learned how zones are created and talked about ZoneSpecification, but there are still many handlers not mentioned.
This is what they can also do:
- Catch and handle errors (
- Modify the callback at the creation stage (
- Create a repeating timer (
- Affect standard logging (
- Influence the zone fork process (
That’s it for today. In this article we looked at examples of using zones in two small libraries and hopefully understood their basic features. In the next article, we’ll analyze details that aren’t self-evident on AngularDart — a framework that has slotted into our frontend life more than others.
Feel free to leave a comment below with your thoughts. I’d be more than happy to answer your questions!