Angular Transition Zone

antischematic

Michael Muscat

Posted on May 4, 2022

Angular Transition Zone

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>
Enter fullscreen mode Exit fullscreen mode
@Component({
   selector: "button",
   templateUrl: "./button.html"
})
export class ButtonComponent {
   pending: boolean
}
Enter fullscreen mode Exit fullscreen mode
<!-- usage -->
<button (click)="doAsync()">Click me!</button>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
   };
};

Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
@Component({
   selector: "button",
   templateUrl: "./button.html"
})
export class ButtonComponent {
   get pending() {
      return isPending()
   }

   @HostListener("click")
   handleClick() {
      startTransition()
   }
}
Enter fullscreen mode Exit fullscreen mode
<!-- usage -->
<button (click)="doAsync()">Click me!</button>
Enter fullscreen mode Exit fullscreen mode

This pseudo-code serves to illustrate two important features of the transition we wish to implement:

  1. We can trigger the start of a transition
  2. 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
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
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) {}
}
Enter fullscreen mode Exit fullscreen mode

This time we have a concrete service to wire transitions to the component.

<!-- async.html -->
<button (click)="doAsync()">Click me!</button>
Enter fullscreen mode Exit fullscreen mode
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) {}
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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() 
      }
   }
}
Enter fullscreen mode Exit fullscreen mode

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()
   }))
Enter fullscreen mode Exit fullscreen mode

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!

💖 💪 🙅 🚩
antischematic
Michael Muscat

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