Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ core/www/
# playwright
core/test-results/
core/playwright-report/
packages/angular/test-results/

# ground truths generated outside of docker should not be committed to the repo
core/**/*-snapshots/*
Expand Down
19 changes: 16 additions & 3 deletions BREAKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,17 @@ Apps that relied on `ionChange` firing on every confirmation (for example, to de

Ionic 9 requires Angular 18 or later. Angular 16 and 17 are no longer supported.

**Angular 21 Requires Explicit Zone Change Detection**
**Zoneless Change Detection by Default**

Angular 21 defaults `bootstrapModule()` and `bootstrapApplication()` to zoneless change detection. `zone.js` in your polyfills is ignored unless you opt back in explicitly, which surfaces as runtime `NG0909` errors and breaks change detection for asynchronous updates (modal and popover lifecycle, tab navigation, and anything depending on async-resolved state). Ionic 9 relies on zone-based change detection, so apps on Angular 21 must provide it explicitly.
Ionic 9 defaults to zoneless change detection. Angular 21 bootstraps zoneless out of the box, so a new Ionic 9 app on Angular 21 runs without Zone.js and requires no change-detection provider. The `ng add @ionic/angular` schematic no longer registers `provideZoneChangeDetection()`.

Because Zone.js no longer triggers change detection automatically, component state that you update from an asynchronous callback Angular doesn't wrap (awaiting an overlay result such as `modal.onWillDismiss()`, `setTimeout`, RxJS subscriptions, `Platform` events) no longer re-renders on its own. Update a signal or call `ChangeDetectorRef.markForCheck()` in those callbacks. Template event bindings, `@HostListener`, reactive forms, and Ionic lifecycle hooks (`ionViewWillEnter`, etc.) that set state synchronously are unaffected. Refer to the [Zoneless Change Detection guide](https://ionicframework.com/docs/angular/zoneless) for the patterns.

On Angular 18 through 20, Zone.js remains Angular's default, so those versions are unaffected and require no change. To adopt zoneless there, add `provideZonelessChangeDetection()` (named `provideExperimentalZonelessChangeDetection()` on Angular 18 and 19).

**Keeping Zone.js on Angular 21 (optional)**

To keep using Zone.js on Angular 21, opt back in with `provideZoneChangeDetection()` and keep `zone.js` in your polyfills.

Standalone bootstrap:

Expand Down Expand Up @@ -160,7 +168,12 @@ NgModule bootstrap:
.catch((err) => console.error(err));
```

Angular forbids `provideZoneChangeDetection()` inside an NgModule's `providers` array, so for NgModule apps it must be passed as `applicationProviders` on the `bootstrapModule()` call. This step is only required on Angular 21. Angular 18 through 20 are unaffected.
Angular forbids `provideZoneChangeDetection()` inside an NgModule's `providers` array, so for NgModule apps it must be passed as `applicationProviders` on the `bootstrapModule()` call. Both paths also require `zone.js` in your polyfills, which Angular 21's default scaffold omits:

```ts
// src/polyfills.ts
import 'zone.js';
```

**TypeScript**

Expand Down
5 changes: 5 additions & 0 deletions packages/angular-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@
"rxjs": ">=7.5.0",
"zone.js": ">=0.13.0"
},
"peerDependenciesMeta": {
"zone.js": {
"optional": true
}
},
"devDependencies": {
"@angular/animations": "^21.0.0",
"@angular/common": "^21.0.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/angular/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ This guide shows you how to test the local Ionic Framework build with a new Angu
```sh
# Change to whichever directory you want the app in
cd ~/Documents/
ng new my-app --style=css --ssr=false --zoneless=false
ng new my-app --style=css --ssr=false
cd my-app
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export class StackController {
createView(ref: ComponentRef<any>, activatedRoute: ActivatedRoute): RouteView {
const url = getUrl(this.router, activatedRoute);
const element = ref?.location?.nativeElement as HTMLElement;
const unlistenEvents = bindLifecycleEvents(this.zone, ref.instance, element);
const unlistenEvents = bindLifecycleEvents(this.zone, ref.changeDetectorRef, ref.instance, element);
return {
id: this.nextId++,
stackId: computeStackId(this.tabsPrefix, url),
Expand Down
2 changes: 1 addition & 1 deletion packages/angular/common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export { NavController } from './providers/nav-controller';
export { Config, ConfigToken } from './providers/config';
export { Platform } from './providers/platform';

export { AngularDelegate, bindLifecycleEvents, IonModalToken } from './providers/angular-delegate';
export { AngularDelegate, IonModalToken } from './providers/angular-delegate';

export type { IonicWindow } from './types/interfaces';
export type { ViewDidEnter, ViewDidLeave, ViewWillEnter, ViewWillLeave } from './types/ionic-lifecycle-hooks';
Expand Down
26 changes: 23 additions & 3 deletions packages/angular/common/src/providers/angular-delegate.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
ApplicationRef,
ChangeDetectorRef,
ComponentRef,
createComponent,
EnvironmentInjector,
Expand Down Expand Up @@ -228,7 +229,7 @@ export const attachView = (
hostElement.classList.add(cssClass);
}
}
const unbindEvents = bindLifecycleEvents(zone, instance, hostElement);
const unbindEvents = bindLifecycleEvents(zone, componentRef.changeDetectorRef, instance, hostElement);
container.appendChild(hostElement);

applicationRef.attachView(componentRef.hostView);
Expand All @@ -246,10 +247,29 @@ const LIFECYCLES = [
LIFECYCLE_WILL_UNLOAD,
];

export const bindLifecycleEvents = (zone: NgZone, instance: any, element: HTMLElement): (() => void) => {
export const bindLifecycleEvents = (
zone: NgZone,
changeDetectorRef: ChangeDetectorRef,
instance: any,
element: HTMLElement
): (() => void) => {
/**
* `zone.run` keeps the listener registration (and, under Zone.js, the handler
* execution) inside the Angular zone, so async work started inside a lifecycle
* hook is still zone-tracked. Under zoneless Angular it is a passthrough.
*/
return zone.run(() => {
const unregisters = LIFECYCLES.filter((eventName) => typeof instance[eventName] === 'function').map((eventName) => {
const handler = (ev: any) => instance[eventName](ev.detail);
const handler = (ev: any) => {
instance[eventName](ev.detail);
/**
* Ionic lifecycle events (`ionViewWillEnter`, etc.) are dispatched from
* the web component via a native event listener, so under zoneless
* Angular nothing schedules change detection for state the hook mutates.
* Mark the view dirty explicitly. This is a no-op-or-better under Zone.js.
*/
changeDetectorRef.markForCheck();
};
element.addEventListener(eventName, handler);
return () => element.removeEventListener(eventName, handler);
});
Expand Down
5 changes: 5 additions & 0 deletions packages/angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@
"rxjs": ">=7.5.0",
"zone.js": ">=0.13.0"
},
"peerDependenciesMeta": {
"zone.js": {
"optional": true
}
},
"devDependencies": {
"@angular-devkit/core": "^21.0.0",
"@angular-devkit/schematics": "^21.0.0",
Expand Down
30 changes: 0 additions & 30 deletions packages/angular/src/schematics/add/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,35 +93,6 @@ function addProvideIonicAngular(projectName: string, projectSourceRoot: Path): R
};
}

/**
* Adds `provideZoneChangeDetection()` to a standalone project's app config.
* Angular 21 makes both `bootstrapApplication` and `bootstrapModule` default
* to zoneless change detection, which breaks Ionic's NgZone-based async
* lifecycle. Registering the provider explicitly preserves the existing
* zone-based default on Angular 18-20 and is required on 21+.
*
* NgModule-based projects are equally affected on 21+ but are not handled
* here: the reliable place to opt out of zoneless for `bootstrapModule` is
* its `applicationProviders` option in main.ts, not `AppModule.providers`,
* and this schematic does not yet edit main.ts. Users upgrading an existing
* NgModule project to ng21 must add it manually.
*
* @param projectName The name of the project.
* @param projectSourceRoot The source root path of the project.
*/
function addProvideZoneChangeDetection(projectName: string, projectSourceRoot: Path): Rule {
return (host: Tree) => {
const appConfig = `${projectSourceRoot}/app/app.config.ts`;
if (host.exists(appConfig)) {
return addRootProvider(
projectName,
({ code, external }) => code`${external('provideZoneChangeDetection', '@angular/core')}()`
);
}
return host;
};
}

function addIonicStyles(projectName: string, projectSourceRoot: Path): Rule {
return (host: Tree) => {
const ionicStyles = [
Expand Down Expand Up @@ -248,7 +219,6 @@ export default function ngAdd(options: IonAddOptions): Rule {
addIonicAngularToolkitToAngularJson(),
addIonicAngularModuleToAppModule(sourcePath),
addProvideIonicAngular(options.project, sourcePath),
addProvideZoneChangeDetection(options.project, sourcePath),
addIonicBuilder(options.project),
addIonicStyles(options.project, sourcePath),
addIonicons(options.project, sourcePath),
Expand Down
6 changes: 3 additions & 3 deletions packages/angular/test/apps/ng21/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions packages/angular/test/apps/ng21/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,7 @@
"ionicons": "^8.0.13",
"rxjs": "^7.8.0",
"tslib": "^2.3.0",
"typescript-eslint-language-service": "^4.1.5",
"zone.js": "^0.16.0"
"typescript-eslint-language-service": "^4.1.5"
},
"devDependencies": {
"@angular-devkit/build-angular": "^21.0.0",
Expand Down
7 changes: 2 additions & 5 deletions packages/angular/test/apps/ng21/src/main-standalone.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { provideZoneChangeDetection } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { RouteReuseStrategy, provideRouter } from '@angular/router';
import { provideIonicAngular, IonicRouteStrategy } from '@ionic/angular/standalone';
Expand All @@ -8,12 +7,10 @@ import { AppStandaloneComponent } from './app/app-standalone.component';
import { routes } from './app/app.routes';

export const bootstrapStandalone = () => {
// Angular 21 defaults bootstrapApplication to zoneless `NoopNgZone`, which
// breaks Ionic's NgZone-based async lifecycle change detection. Registering
// provideZoneChangeDetection() explicitly opts back into zone-based CD.
// Ionic 9 defaults to zoneless change detection. Angular 21 bootstraps zoneless
// out of the box, so no change-detection provider is registered here.
bootstrapApplication(AppStandaloneComponent, {
providers: [
provideZoneChangeDetection(),
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
provideRouter(routes),
provideIonicAngular({ keyboardHeight: 12345 })
Expand Down
11 changes: 4 additions & 7 deletions packages/angular/test/apps/ng21/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { enableProdMode, provideZoneChangeDetection } from '@angular/core';
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { AppModule } from './app/app.module';
Expand All @@ -12,13 +12,10 @@ const isLazy = window.location.href.includes('lazy');

if (isLazy) {
document.addEventListener('DOMContentLoaded', () => {
// Angular 21 defaults bootstrapModule to zoneless change detection, which
// breaks Ionic's NgZone-based async lifecycle. AppModule.providers is too
// late to opt back in; the override has to go on bootstrapModule's options.
// Ionic 9 defaults to zoneless change detection. Angular 21 bootstraps
// zoneless out of the box, so no change-detection provider is registered.
platformBrowserDynamic()
.bootstrapModule(AppModule, {
applicationProviders: [provideZoneChangeDetection()],
})
.bootstrapModule(AppModule)
.catch(err => console.error(err));
});
} else {
Expand Down
7 changes: 7 additions & 0 deletions packages/angular/test/apps/ng21/src/polyfills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Ionic 9 defaults to zoneless change detection. The ng21 test app runs without
* Zone.js, so this polyfills file intentionally does NOT import 'zone.js' (unlike
* the shared base polyfills used by the ng18-20 apps, which still run zone-based).
*
* Application-level polyfills, if any are ever needed, go below.
*/
9 changes: 6 additions & 3 deletions packages/angular/test/base/e2e/src/lazy/form.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,8 +290,11 @@ test.describe('Form', () => {
}

async function testData(page: any, data: any) {
const text = await page.locator('#data').textContent();
const value = JSON.parse(text!);
expect(value).toEqual(data);
// Zoneless change detection is scheduled asynchronously, so a one-shot read
// can race the render. Poll instead of reading textContent once.
await expect.poll(async () => {
const text = await page.locator('#data').textContent();
return JSON.parse(text!);
}).toEqual(data);
}
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Component, NgZone } from '@angular/core';
import { AlertController } from '@ionic/angular';

import { assertZoneContext } from '../../zone-assert.util';

@Component({
selector: 'app-alert',
templateUrl: './alert.component.html',
Expand Down Expand Up @@ -29,7 +31,7 @@ export class AlertComponent {
text: 'Cancel',
handler: () => {
console.log(NgZone.isInAngularZone());
NgZone.assertInAngularZone();
assertZoneContext();
}
}
]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Component, Input, NgZone, OnInit, Optional } from '@angular/core';
import { Component, Input, OnInit, Optional } from '@angular/core';
import { UntypedFormControl, UntypedFormGroup } from '@angular/forms';
import { ModalController, IonNav, ViewWillLeave, ViewDidEnter, ViewDidLeave } from '@ionic/angular';

import { assertZoneContext } from '../../zone-assert.util';

@Component({
selector: 'app-modal-example',
templateUrl: './modal-example.component.html',
Expand Down Expand Up @@ -30,7 +32,7 @@ export class ModalExampleComponent implements OnInit, ViewWillLeave, ViewDidEnte
) {}

ngOnInit() {
NgZone.assertInAngularZone();
assertZoneContext();
this.onInit++;
}

Expand All @@ -42,19 +44,19 @@ export class ModalExampleComponent implements OnInit, ViewWillLeave, ViewDidEnte
if (this.onInit !== 1) {
throw new Error('ngOnInit was not called');
}
NgZone.assertInAngularZone();
assertZoneContext();
this.willEnter++;
}
ionViewDidEnter() {
NgZone.assertInAngularZone();
assertZoneContext();
this.didEnter++;
}
ionViewWillLeave() {
NgZone.assertInAngularZone();
assertZoneContext();
this.willLeave++;
}
ionViewDidLeave() {
NgZone.assertInAngularZone();
assertZoneContext();
this.didLeave++;
}
closeModal() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AfterViewInit, Component } from "@angular/core";
import { AfterViewInit, ChangeDetectorRef, Component } from "@angular/core";

/**
* Validates that inline modals will correctly display
Expand All @@ -16,9 +16,13 @@ export class ModalInlineComponent implements AfterViewInit {

breakpointDidChangeCounter = 0;

constructor(private cdr: ChangeDetectorRef) {}

ngAfterViewInit(): void {
setTimeout(() => {
this.items = ['A', 'B', 'C', 'D'];
// Zoneless: state set in an async callback Angular does not wrap won't re-render on its own; mark the view dirty.
this.cdr.markForCheck();
}, 1000);
}

Expand Down
Loading
Loading