Angular Transition Zone
Michael Muscat
Posted on May 4, 2022
Have you heard of zone.js? It's the secret sauce behind Angular's change detection mechanism. Whenever something async happens, Angular knows because zone.js knows. You are probably already using fakeAsync
in your unit tests to suspend async tasks entirely!
The power to intercept, manipulate and schedule tasks in the JavaScript VM. That's zone.js in a nutshell. But what does that have to do with transitions?
A Tricky Problem
Let's say I have a button and I want to do some work when it is clicked. I also want to show a spinner while the work is happening until it is done.
<!-- ./button.html -->
<ng-content></ng-content>
<spinner *ngIf="pending"></spinner>
@Component({
selector: "button",
templateUrl: "./button.html"
})
export class ButtonComponent {
pending: boolean
}
<!-- usage -->
<button (click)="doAsync()">Click me!</button>
How do I know when to show and stop the spinner? Perhaps I could pass it in as an @Input()
.
<!-- just add an input? -->
<button (click)="doAsync()" [pending]="pending">
Click me!
</button>
But now there's an extra piece of state to manage. What if the button click ends up triggering some REST APIs followed by some navigation event that loads a bunch of async resolvers? It would be too cumbersome to keep track of it all.
That's where zone.js comes in. Instead of manually tracking every bit of async activity, we'll let zone.js tell us when all of the work is done instead.
We'll call this process a transition.
Zone.js Primer
By default every Angular application runs in the Angular zone. This zone is responsible for triggering change detection which updates the view. Without this we would need to manually tell Angular when to run change detection after every async task.
Zone.js works by monky-patching JavaScript globals such as setTimeout
, Promise
and addEventListener
. The following example was taken from Zone Primer on Google Docs
// How Zone.js Works
// Save the original reference to setTimeout
let originalSetTimeout = window.setTimeout;
// Overwrite the API with a function which wraps callback in zone.
window.setTimeout = function(callback, delay) {
// Invoke the original API but wrap the callback in zone.
return originalSetTimeout(
// Wrap the callback method
Zone.current.wrap(callback),
delay
);
}
// Return a wrapped version of the callback which restores zone.
Zone.prototype.wrap = function(callback) {
// Capture the current zone
let capturedZone = this;
// Return a closure which executes the original closure in zone.
return function() {
// Invoke the original callback in the captured zone.
return capturedZone.runGuarded(callback, this, arguments);
};
};
The nice thing about Zone.js is that it's very easy to create a new Zone by forking an existing one. We will implement transitions by forking the Angular zone.
Transition API
Before we look at the implementation, let's reconsider the button example. What should a transition look like?
<!-- ./button.html -->
<ng-content></ng-content>
<spinner *ngIf="pending"></spinner>
@Component({
selector: "button",
templateUrl: "./button.html"
})
export class ButtonComponent {
get pending() {
return isPending()
}
@HostListener("click")
handleClick() {
startTransition()
}
}
<!-- usage -->
<button (click)="doAsync()">Click me!</button>
This pseudo-code serves to illustrate two important features of the transition we wish to implement:
- We can trigger the start of a transition
- We can observe the status of a transition
The spinner knows nothing about what work will be performed. Zone.js will tell us the work is done when isPending()
returns false.
Let's refine this into something a little more concrete.
// transition interface
interface Transition {
start(token: TransitionToken): void
has(token: TransitionToken): boolean
invoke(task: Function): any
}
interface TransitionToken {
name: string
}
start is the signal to begin a new transition. If a transition is already running, the previous transition is discarded. We'll associate each transition with a TransitionToken
. For the next tick, all work that runs in the transition zone will be associated with this token.
has checks if a transition associated with TransitionToken
is currently active, returning true if it is.
invoke immediately runs the callback it receives inside the transition zone. That way we only capture work that should be considered part of the transition.
Let's look at the button example again.
<!-- ./button.html -->
<ng-content></ng-content>
<spinner *ngIf="pending"></spinner>
const Click = new TransitionToken("Click")
@Component({
selector: "button",
templateUrl: "./button.html",
providers: [Transition]
})
export class ButtonComponent {
get pending() {
return this.transition.has(Click)
}
@HostListener("click")
handleClick() {
this.transition.start(Click)
}
constructor(private transition: Transition) {}
}
This time we have a concrete service to wire transitions to the component.
<!-- async.html -->
<button (click)="doAsync()">Click me!</button>
import { timer } from "rxjs"
@Component({
templateUrl: "./async.html",
providers: [Transition]
})
class AsyncComponent {
doAsync() {
this.transition.invoke(() => {
// sleep for 2 seconds
timer(2000).subscribe()
})
}
constructor(private transition: Transition) {}
}
The actual async work will be simulated with a 2 second timer that runs in the transition zone. From this example we should expect the spinner to spin for exactly 2 seconds once the button is clicked.
Transition Zone Implementation
For the basic implementation refer to this gist.
To implement transitions we need to fork an existing zone.
Zone.current.fork(spec) // <-- creates a new child zone
To do this we write a ZoneSpec
.
class TransitionZoneSpec implements ZoneSpec {
properties = {
count: 0
}
onScheduleTask(delegate: ZoneDelegate, current: Zone, target: Zone, task: Task): Task {
this.properties.count++
return delegate.scheduleTask(target, task)
}
onInvokeTask(delegate: ZoneDelegate, current: Zone, target: Zone, task: Task, applyThis: any, applyArgs: any[] | undefined) {
this.properties.count--
return delegate.invokeTask(target, task, applyThis, applyArgs)
}
onHasTask() {
// no more macrotasks or microtasks left in the queue
if (this.properties.count === 0) {
done()
}
}
}
This is where zone.js allows us to jump in and take control of the JavaScript VM. Not quite there will be dragons, but just enough for us to be dangerous.
onScheduleTask let's us intercept the beginning of some async work that has not yet been scheduled. When you call something like setTimeout
, zone.js will intercept that call and let us choose whether to schedule (or discard) it. For transitions we are only interested in counting the number of tasks that are scheduled.
onInvokeTask let's us intercept when the callback of some async work is about to be called. For example, when setTimeout(fn, 1000)
is scheduled, the call to fn
is the task that is intercepted. Again we get to choose whether or not to invoke the task. For transitions we are only interested in counting the number of tasks that are invoked.
onHasTask let's us know when work has been scheduled or completed. We can use this to inspect the state of our transition zone. When the task count returns to zero, the transition is "done".
Testing
We can test that our transition is working by writing a simple test (see reference implementation). We also want to verify that transitions survive async boundaries. This test uses nested setTimeout
calls to simulate sequential async calls, such as a template rendering after fetching some data.
it("should have transition while async tasks are pending", fakeAsync(() => {
const token = new TransitionToken("Test")
const transition = startTransition(token)
const ngZone = TestBed.inject(NgZone)
transition.invoke(() => {
setTimeout(() => {
ngZone.run(() => {
transition.invoke(() => {
setTimeout(() => {
// nested
}, 500)
})
})
}, 1000)
})
// 0ms
expect(transition.has(token)).toBeTrue()
tick(500)
// 500ms
expect(transition.has(token)).toBeTrue()
tick(500)
// 1000ms start nested timeout
expect(transition.has(token)).toBeTrue()
tick(250)
// 1250ms
expect(transition.has(token)).toBeTrue()
tick(250)
// 1500ms
expect(transition.has(token)).toBeFalse()
}))
Summary
This concept was inspired by concurrent mode in React 18. I really wanted to see if it was possible to do something similar with Angular. I'm pleased to report that it is definitely possible, and with a surprisingly small amount of code. If React concurrent mode works by forking JSX rendering, then the Angular equivalent is to fork zones. The main difference is that React transitions are hidden from the user by running in memory. For Angular this is not possible. But this is less of a problem if you render as you fetch.
There's more work to be done for sure. Perhaps a suspense-like API? ng-cloak
anyone? 😉
Happy Coding!
Posted on May 4, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 30, 2024