Angular 18 has been out for a few months now, biggest highlight of the release is experimental support for zoneless change detection. That is right, there is a possibility in the distant future that zone.js
will not be shipped with Angular. If you go to the zone.js repo, you can see that it’s no longer accepting new features and low-priority bug fixes.
I will break this post into two parts, first explaining what zone.js does & how it works with Angular. Secondly, providing steps and examples of going zoneless.
Zone.js
Zone.js
is a library that Angular uses as a signal mechanism
that captures asynchronous operations such as timers, network requests, & event listeners. Angular schedules change detection based on the signals from Zone.js.
It achieves this by patching default window tasks/microtasks. There are two patching mechanisms used, wrapping & task.
Patching mechanisms
Task
Zone.js treats certain APIs as Tasks, similar to the JavaScript virtual machine, applications can receive onScheduleTask
, onInvokeTask
, onCancelTask
, & onHashTask
callbacks. There are three task categories patched:
- MacroTasks (e.g. setTimeout, setInterval, clearTimeout, XMLHttp)
- MicroTasks (e.g. Promise)
- EventTasks (e.g. click, blur, drag, scroll, etc.)
In this example, let’s take a look at timers.ts - A utility function used to patch timers & request animation frame.
During the application launching phase, Zone.js will patch the browser timers:
export function patchBrowser(Zone: ZoneType): void {
// ...
Zone.__load_patch('timers', (global: any) => {
const set = 'set';
const clear = 'clear';
patchTimer(global, set, clear, 'Timeout');
patchTimer(global, set, clear, 'Interval');
patchTimer(global, set, clear, 'Immediate');
});
// ...
}
Referenced from browser.ts
The patchTimer
utility function will update the logic of the default timeout functionality. It will create a new zone task & schedule the macro-task in the current zone whenever a new timer function is executed.
/**
* Will schedule a new macro task with current zone using
* Zone.current.scheduleMacroTask(...)
*/
const task = scheduleMacroTaskWithCurrentZone(
setName, // `set${timerName}` -> Ex. setTimeout
args[0], // Function timer -> Newly created function w/ arguments
options, // Timer options -> { isRefreshable, isPeriodic, delay, args }
scheduleTask, // Patched scheduleTask function
clearTask, // patch clearTask function
);
Referenced from timer.ts
When the timer callback is executed, angular is signaled to trigger change detection for the relevant zone. There are multiple zones in an angular application, so only the zone that needs to know that a macro-task was scheduled will know.
Wrap
There are only a few instances of patching by wrapping - most have been migrated to the task approach. Wrap approach wraps a function inside a new function that executes within a specific zone. Let’s see how the MutationObserver
from the window native API is patched.
export function patchBrowser(Zone: ZoneType): void {
// ...
Zone.__load_patch('MutationObserver', (global: any, Zone: ZoneType, api: _ZonePrivate) => {
patchClass('MutationObserver');
patchClass('WebKitMutationObserver');
});
// ...
}
Referenced from browser.ts
The patchClass
function will do the following:
- It will store the original class with the custom zone symbol prefix. Ex.
Symbol(MutationObserver)
->Symbol (__zone_symbol__MutationObserver)
.
export function __symbol__(name: string) {
const symbolPrefix = global['__Zone_symbol_prefix'] || '__zone_symbol__';
return symbolPrefix + name;
}
const originalInstanceKey = zoneSymbol('originalInstance');
// wrap some native API on `window`
export function patchClass(className: string) {
const OriginalClass = _global[className];
if (!OriginalClass) return;
// keep original class in global
_global[zoneSymbol(className)] = OriginalClass;
// ...
}
Referenced from utils.ts & zone-impl.ts
- The original global class is replaced with the patched version with a new constructor function. When a new instance of the global class is created, all of the function arguments are wrapped with Zone.js tracking using
Zone.current.wrap(callback, source)
.
_global[className] = function () {
const a = bindArguments(<any>arguments, className);
switch (a.length) {
case 0:
this[originalInstanceKey] = new OriginalClass();
break;
case 1:
this[originalInstanceKey] = new OriginalClass(a[0]);
break;
case 2:
this[originalInstanceKey] = new OriginalClass(a[0], a[1]);
break;
case 3:
this[originalInstanceKey] = new OriginalClass(a[0], a[1], a[2]);
break;
case 4:
this[originalInstanceKey] = new OriginalClass(a[0], a[1], a[2], a[3]);
break;
default:
throw new Error('Arg list too long.');
}
};
Referenced from utils.ts
- A dummy instance of the class is created to iterate over all properties of the original class.
- If the property is a function, it will replace it with a wrapper function. If it is a property, a setter & getter functions are created.
- The setter function is wrapped with Zone.js tracking using
Zone.current.wrap(callback, source)
.
export function patchClass(className: string) {
// ...
// attach original delegate to patched function
attachOriginToPatched(_global[className], OriginalClass);
const instance = new OriginalClass(function () {});
let prop;
for (prop in instance) {
(function (prop) {
if (typeof instance[prop] === 'function') {
_global[className].prototype[prop] = function () {
return this[originalInstanceKey][prop].apply(this[originalInstanceKey], arguments);
};
} else {
ObjectDefineProperty(_global[className].prototype, prop, {
set: function (fn) {
if (typeof fn === 'function') {
this[originalInstanceKey][prop] = wrapWithCurrentZone(fn, className + '.' + prop);
// keep callback in wrapped function so we can
// use it in Function.prototype.toString to return
// the native one.
attachOriginToPatched(this[originalInstanceKey][prop], fn);
} else {
this[originalInstanceKey][prop] = fn;
}
},
get: function () {
return this[originalInstanceKey][prop];
},
});
}
})(prop);
}
// Copy static props
for (prop in OriginalClass) {
if (prop !== 'prototype' && OriginalClass.hasOwnProperty(prop)) {
_global[className][prop] = OriginalClass[prop];
}
}
}
Referenced from utils.ts
How Angular Gets Notified by Zone.js
There are 3 stages to how Angular is made aware of when to run a change detection cycle.
- Zone.js patches the browsers’ Async APIs.
If you look at your angular.json
, you will see a polyfill
attribute that has zone.js
listed. This means that zone.js
is loaded before the app code and made available globally. This is to ensure the environment is ready before any application logic runs.
- Angular creates a child zone & passes a set of rules that should be followed for the zone. When the task has finished executing,
onHasTask
callback is executed which willcheckStable
function.
function forkInnerZoneWithAngularBehavior(zone: NgZonePrivate) {
const delayChangeDetectionForEventsDelegate = () => {
delayChangeDetectionForEvents(zone);
};
const instanceId = ngZoneInstanceId++;
zone._inner = zone._inner.fork({
name: 'angular',
properties: <any>{
[isAngularZoneProperty]: true,
[angularZoneInstanceIdProperty]: instanceId,
[angularZoneInstanceIdProperty + instanceId]: true,
},
onInvokeTask: (
delegate: ZoneDelegate,
current: Zone,
target: Zone,
task: Task,
applyThis: any,
applyArgs: any,
): any => {
// Prevent triggering change detection when the flag is detected.
if (shouldBeIgnoredByZone(applyArgs)) {
return delegate.invokeTask(target, task, applyThis, applyArgs);
}
try {
onEnter(zone);
return delegate.invokeTask(target, task, applyThis, applyArgs);
} finally {
if (
(zone.shouldCoalesceEventChangeDetection && task.type === 'eventTask') ||
zone.shouldCoalesceRunChangeDetection
) {
delayChangeDetectionForEventsDelegate();
}
onLeave(zone);
}
},
onInvoke: (
delegate: ZoneDelegate,
current: Zone,
target: Zone,
callback: Function,
applyThis: any,
applyArgs?: any[],
source?: string,
): any => {
try {
onEnter(zone);
return delegate.invoke(target, callback, applyThis, applyArgs, source);
} finally {
if (
zone.shouldCoalesceRunChangeDetection &&
!zone.callbackScheduled &&
!isSchedulerTick(applyArgs)
) {
delayChangeDetectionForEventsDelegate();
}
onLeave(zone);
}
},
onHasTask: (
delegate: ZoneDelegate,
current: Zone,
target: Zone,
hasTaskState: HasTaskState,
) => {
delegate.hasTask(target, hasTaskState);
if (current === target) {
// We are only interested in hasTask events which originate from our zone
// (A child hasTask event is not interesting to us)
if (hasTaskState.change == 'microTask') {
zone._hasPendingMicrotasks = hasTaskState.microTask;
updateMicroTaskStatus(zone);
checkStable(zone);
} else if (hasTaskState.change == 'macroTask') {
zone.hasPendingMacrotasks = hasTaskState.macroTask;
}
}
},
onHandleError: (delegate: ZoneDelegate, current: Zone, target: Zone, error: any): boolean => {
delegate.handleError(target, error);
zone.runOutsideAngular(() => zone.onError.emit(error));
return false;
},
});
}
Referenced from ng_zone.ts
- When there is no
pendingMicrotasks
,zone.onMicrotaskEmpty.emit(null)
is emitted.
function checkStable(zone: NgZonePrivate) {
if (zone._nesting == 0 && !zone.hasPendingMicrotasks && !zone.isStable) {
try {
zone._nesting++;
zone.onMicrotaskEmpty.emit(null);
} finally {
zone._nesting--;
if (!zone.hasPendingMicrotasks) {
try {
zone.runOutsideAngular(() => zone.onStable.emit(null));
} finally {
zone.isStable = true;
}
}
}
}
}
Referenced from ng_zone.ts
- Angular change detection scheduler,
NgZoneChangeDetectionScheduler
listens foronMicrotaskEmpty
event. When an event is emitted, a change detection cycle is initiated usingthis.applicationRef.tick
. The service is provided in the root by create application API that implements the core application creation logic.
@Injectable({providedIn: 'root'})
export class NgZoneChangeDetectionScheduler {
private readonly zone = inject(NgZone);
private readonly changeDetectionScheduler = inject(ChangeDetectionScheduler);
private readonly applicationRef = inject(ApplicationRef);
private _onMicrotaskEmptySubscription?: Subscription;
initialize(): void {
if (this._onMicrotaskEmptySubscription) {
return;
}
this._onMicrotaskEmptySubscription = this.zone.onMicrotaskEmpty.subscribe({
next: () => {
// `onMicroTaskEmpty` can happen _during_ the zoneless scheduler change detection because
// zone.run(() => {}) will result in `checkStable` at the end of the `zone.run` closure
// and emit `onMicrotaskEmpty` synchronously if run coalsecing is false.
if (this.changeDetectionScheduler.runningTick) {
return;
}
this.zone.run(() => {
this.applicationRef.tick();
});
},
});
}
ngOnDestroy() {
this._onMicrotaskEmptySubscription?.unsubscribe();
}
}
Referenced from ng_zone_scheduling.ts
Do we need it?
For the time being, YES. Zoneless is still experimental! Perhaps, it will have the same fate as the view engine - a multi-major version transition period after release. I still have not seen adoption from major angular libraries.
Historically, Angular has relied heavily on Zone.js for change detection, but with signal-based change detection introduced, zone.js may be a thing of the past.
Until next time, when we go zoneless with the help of signals!