How does Zoneless Angular Work?

November 8, 2024

874 words

Post contents

Warning

This article talks in-depth about technical specifics of the provideExperimentalZonelessChangeDetection experiment present in Angular 18 and 19. The mechanisms discussed in this article are likely to change before this experiment is made production-ready.

Recently I was live on my Twitch stream coding away until I got massively nerd sniped away from my discussion.

One of my viewers asked me:

How does Zoneless bind to events if Zone.js is supposed to be the one monkey-patching EventTarget?

I originally thought that the Zoneless strategy offered by provideExperimentalZonelessChangeDetection didn't do any kind of event binding, but to my surprise the following code works fine without Zone.js:

import { bootstrapApplication } from "@angular/platform-browser";import {	ChangeDetectionStrategy,	Component,	provideExperimentalZonelessChangeDetection,} from "@angular/core";@Component({	selector: "app-root",	standalone: true,	changeDetection: ChangeDetectionStrategy.OnPush,	template: `<button (click)="add()">{{ count }}</button>`,})export class AppComponent {	count = 0;	add() {		this.count++;	}}bootstrapApplication(AppComponent, {	providers: [provideExperimentalZonelessChangeDetection()],});

So wait, if Zone.js isn't here to patch EventTarget, then what is?

Confirming that Zone.js is disabled

Let's first double-check something; let's make sure that Zone.js is honestly and truly disabled in our code sample.

If it were enabled, we'd expect any kind of addEventListener to trigger change detection. Taking another look and sure enough, a addEventListener added after-the-template compilation still triggers change detection when Zone.js is imported:

import "zone.js";import { bootstrapApplication } from "@angular/platform-browser";import { AfterViewInit, Component, ElementRef, viewChild } from "@angular/core";@Component({	selector: "app-root",	standalone: true,	// Must not be `OnPush` to demonstrate this behavior working	template: `<button #el>{{ count }}</button>`,})export class AppComponent implements AfterViewInit {	count = 0;	el = viewChild.required<ElementRef>("el");	ngAfterViewInit() {		// Can't be OnPush is because we're not properly marking this component		// as a dirty one for checking, so OnPush bypasses checking this node.		this.el()!.nativeElement.addEventListener("click", this.add.bind(this));	}	add() {		this.count++;	}}bootstrapApplication(AppComponent);

Now if we remove Zone.js using provideExperimentalZonelessChangeDetection, we'd expect this demo to break the intended functionality:

@Component({  selector: 'app-root',  standalone: true,  template: `<button #el>{{ count }}</button>`,})export class AppComponent implements AfterViewInit {  count = 0;  el = viewChild.required<ElementRef>('el');  ngAfterViewInit() {  	// Can't be OnPush is because we're not properly marking this component  	// as a dirty one for checking, so OnPush bypasses checking this node.    this.el()!.nativeElement.addEventListener('click', this.add.bind(this));  }  add() {    this.count++;  }}

Sure enough, this is the case. Why does this demo break?

Well, it's because:

  • Zone.js patches EventTarget to call tick
  • When we remove Zone.js from our bundle to make our app Zoneless, it removes this EventTarget patch

But wait, if this is true, how does the first code sample work with no Zoneless change detection? Surely, Angular must be notified when the user clicks on the event?

Well, it does, but it doesn't do so using EventTarget.

How does Angular bind to events?

Let's look at how Angular triggers addEventListener. First, we look at DomEventsPlugin which is the actual code that called element.addEventListener:

@Injectable()export class DomEventsPlugin extends EventManagerPlugin {  // ...  override addEventListener(element: HTMLElement, eventName: string, handler: Function): Function {    element.addEventListener(eventName, handler as EventListener, false);    return () => this.removeEventListener(element, eventName, handler as EventListener);  }  // ...}

Then, it's called from EventManager:

@Injectable()export class EventManager {  // ...    addEventListener(element: HTMLElement, eventName: string, handler: Function): Function {    const plugin = this._findPluginFor(eventName);    return plugin.addEventListener(element, eventName, handler);  }      // ...}

Angular makes this event manager more generic to replace the handling of events like panstart from Hammer.js

This is then called from DefaultDomRenderer2, which is the default for web apps:

class DefaultDomRenderer2 implements Renderer2 {  // ...      listen(    target: 'window' | 'document' | 'body' | any,    event: string,    callback: (event: any) => boolean,  ): () => void {    // ...    return this.eventManager.addEventListener(      target,      event,      this.decoratePreventDefault(callback),    ) as VoidFunction;  }}

Which is called from the renderer's implementation of listener:

export function listenerInternal(  tView: TView,  lView: LView<{} | null>,  renderer: Renderer,  tNode: TNode,  eventName: string,  listenerFn: (e?: any) => any,  eventTargetResolver?: GlobalTargetResolver,): void {    // ...    listenerFn = wrapListener(tNode, lView, context, listenerFn);    // ...    const cleanupFn = renderer.listen(target as RElement, eventName, listenerFn);    // ...}

Notice here, how we're wrapping the listenerFn with wrapListener. As it turns out, this wrapper calls markViewDirty, which triggers change detection for the component:

function wrapListener(  tNode: TNode,  lView: LView<{} | null>,  context: {} | null,  listenerFn: (e?: any) => any,): EventListener {  // ...  return function wrapListenerIn_markDirtyAndPreventDefault(e: any) { 	// ...    markViewDirty(startView, NotificationSource.Listener);	// ...}

What we learned

If we apply what we learned while exploring Angular's source code, we can see the differences between how Zone.js and Zoneless apps bind to events in the template.

Zone.js works by patching EventTarget.prototype.addEventListener itself, while provideExperimentalZonelessChangeDetection works by hooking into the compiler to track usage of (event) bindings. This binding then, in turn, calls the markViewDirty hook to update change detection.

That's all for today, but if you'd like to learn more about Angular and how to use it, check out my book that teaches Angular from beginning to end: The Framework Field Guide.

Subscribe to our newsletter!

Subscribe to our newsletter to get updates on new content we create, events we have coming up, and more! We'll make sure not to spam you and provide good insights to the content we have.