From 099c8733a90a55f365bdc6d527f5782db4b53a96 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Wed, 3 Jun 2026 11:42:03 -0700 Subject: [PATCH 1/2] feat(angular): default to zoneless change detection --- .gitignore | 1 + BREAKING.md | 23 ++++++++++++-- packages/angular-server/package.json | 5 ++++ packages/angular/README.md | 2 +- .../directives/navigation/stack-controller.ts | 2 +- packages/angular/common/src/index.ts | 2 +- .../common/src/providers/angular-delegate.ts | 26 ++++++++++++++-- packages/angular/package.json | 5 ++++ packages/angular/src/schematics/add/index.ts | 30 ------------------- .../angular/test/apps/ng21/package-lock.json | 6 ++-- packages/angular/test/apps/ng21/package.json | 3 +- .../test/apps/ng21/src/main-standalone.ts | 7 ++--- packages/angular/test/apps/ng21/src/main.ts | 11 +++---- .../angular/test/apps/ng21/src/polyfills.ts | 7 +++++ .../test/base/e2e/src/lazy/form.spec.ts | 9 ++++-- .../src/app/lazy/alert/alert.component.ts | 4 ++- .../modal-example/modal-example.component.ts | 14 +++++---- .../modal-inline/modal-inline.component.ts | 6 +++- .../src/app/lazy/modal/modal.component.ts | 15 +++++++--- .../nested-outlet-page.component.ts | 8 +++-- .../nested-outlet-page2.component.ts | 8 +++-- .../popover-inline.component.ts | 6 +++- .../app/lazy/providers/providers.component.ts | 25 ++++++++++++---- .../router-link-page.component.ts | 14 +++++---- .../lazy/router-link/router-link.component.ts | 14 +++++---- .../app/lazy/tabs-tab1/tabs-tab1.component.ts | 12 +++++--- .../app/lazy/tabs-tab2/tabs-tab2.component.ts | 12 ++++++-- .../virtual-scroll-detail.component.ts | 14 +++++---- .../virtual-scroll-inner.component.ts | 6 ++-- .../menu-controller.component.ts | 6 ++-- .../test/base/src/app/zone-assert.util.ts | 15 ++++++++++ 31 files changed, 205 insertions(+), 113 deletions(-) create mode 100644 packages/angular/test/apps/ng21/src/polyfills.ts create mode 100644 packages/angular/test/base/src/app/zone-assert.util.ts diff --git a/.gitignore b/.gitignore index e610d8a11dd..d49be2c644e 100644 --- a/.gitignore +++ b/.gitignore @@ -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/* diff --git a/BREAKING.md b/BREAKING.md index 41f9fd48bd2..08f2754cd44 100644 --- a/BREAKING.md +++ b/BREAKING.md @@ -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: @@ -160,7 +168,16 @@ 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'; +``` + +**`bindLifecycleEvents` No Longer Exported** + +The internal `bindLifecycleEvents` helper is no longer exported from `@ionic/angular/common`. It was framework plumbing for wiring Ionic lifecycle events to component instances and was never part of the documented API. Apps don't call it directly. **TypeScript** diff --git a/packages/angular-server/package.json b/packages/angular-server/package.json index f7e10d55036..27c872a011d 100644 --- a/packages/angular-server/package.json +++ b/packages/angular-server/package.json @@ -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", diff --git a/packages/angular/README.md b/packages/angular/README.md index 659bc9fe113..02c30ed026f 100644 --- a/packages/angular/README.md +++ b/packages/angular/README.md @@ -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 ``` diff --git a/packages/angular/common/src/directives/navigation/stack-controller.ts b/packages/angular/common/src/directives/navigation/stack-controller.ts index 0593219d3f4..11a4e2f33fe 100644 --- a/packages/angular/common/src/directives/navigation/stack-controller.ts +++ b/packages/angular/common/src/directives/navigation/stack-controller.ts @@ -41,7 +41,7 @@ export class StackController { createView(ref: ComponentRef, 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), diff --git a/packages/angular/common/src/index.ts b/packages/angular/common/src/index.ts index 91ef464bf0a..4130c3f978c 100644 --- a/packages/angular/common/src/index.ts +++ b/packages/angular/common/src/index.ts @@ -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'; diff --git a/packages/angular/common/src/providers/angular-delegate.ts b/packages/angular/common/src/providers/angular-delegate.ts index bde802ab115..36e496997b8 100644 --- a/packages/angular/common/src/providers/angular-delegate.ts +++ b/packages/angular/common/src/providers/angular-delegate.ts @@ -1,5 +1,6 @@ import { ApplicationRef, + ChangeDetectorRef, ComponentRef, createComponent, EnvironmentInjector, @@ -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); @@ -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); }); diff --git a/packages/angular/package.json b/packages/angular/package.json index 1b6fb0fb5ee..2d56a679442 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -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", diff --git a/packages/angular/src/schematics/add/index.ts b/packages/angular/src/schematics/add/index.ts index e85007bd0de..62cd1386c99 100644 --- a/packages/angular/src/schematics/add/index.ts +++ b/packages/angular/src/schematics/add/index.ts @@ -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 = [ @@ -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), diff --git a/packages/angular/test/apps/ng21/package-lock.json b/packages/angular/test/apps/ng21/package-lock.json index a8b595e0a79..f0e700a684a 100644 --- a/packages/angular/test/apps/ng21/package-lock.json +++ b/packages/angular/test/apps/ng21/package-lock.json @@ -25,8 +25,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", @@ -16680,7 +16679,8 @@ "version": "0.16.2", "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.16.2.tgz", "integrity": "sha512-Eky7p2Z1Ig3NnbfodSPoARCjKBSTFMnE/ACsP1L/XJEfY4SdOFce19BsUCWVwL6K5ABZFy5J3bjcMWffX+YM3Q==", - "license": "MIT" + "license": "MIT", + "peer": true } } } diff --git a/packages/angular/test/apps/ng21/package.json b/packages/angular/test/apps/ng21/package.json index 6d149be7ba3..f50b1244224 100644 --- a/packages/angular/test/apps/ng21/package.json +++ b/packages/angular/test/apps/ng21/package.json @@ -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", diff --git a/packages/angular/test/apps/ng21/src/main-standalone.ts b/packages/angular/test/apps/ng21/src/main-standalone.ts index c47232dd0b5..3a8ba0ae899 100644 --- a/packages/angular/test/apps/ng21/src/main-standalone.ts +++ b/packages/angular/test/apps/ng21/src/main-standalone.ts @@ -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'; @@ -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 }) diff --git a/packages/angular/test/apps/ng21/src/main.ts b/packages/angular/test/apps/ng21/src/main.ts index 49e3d7571a1..f79db60adc9 100644 --- a/packages/angular/test/apps/ng21/src/main.ts +++ b/packages/angular/test/apps/ng21/src/main.ts @@ -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'; @@ -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 { diff --git a/packages/angular/test/apps/ng21/src/polyfills.ts b/packages/angular/test/apps/ng21/src/polyfills.ts new file mode 100644 index 00000000000..8a73714d7ba --- /dev/null +++ b/packages/angular/test/apps/ng21/src/polyfills.ts @@ -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. + */ diff --git a/packages/angular/test/base/e2e/src/lazy/form.spec.ts b/packages/angular/test/base/e2e/src/lazy/form.spec.ts index a5cacabe5e2..844e560284a 100644 --- a/packages/angular/test/base/e2e/src/lazy/form.spec.ts +++ b/packages/angular/test/base/e2e/src/lazy/form.spec.ts @@ -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); } }); diff --git a/packages/angular/test/base/src/app/lazy/alert/alert.component.ts b/packages/angular/test/base/src/app/lazy/alert/alert.component.ts index ec9ff155546..0c0e20ea583 100644 --- a/packages/angular/test/base/src/app/lazy/alert/alert.component.ts +++ b/packages/angular/test/base/src/app/lazy/alert/alert.component.ts @@ -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', @@ -29,7 +31,7 @@ export class AlertComponent { text: 'Cancel', handler: () => { console.log(NgZone.isInAngularZone()); - NgZone.assertInAngularZone(); + assertZoneContext(); } } ] diff --git a/packages/angular/test/base/src/app/lazy/modal-example/modal-example.component.ts b/packages/angular/test/base/src/app/lazy/modal-example/modal-example.component.ts index 8163bc798d0..71b358a347a 100644 --- a/packages/angular/test/base/src/app/lazy/modal-example/modal-example.component.ts +++ b/packages/angular/test/base/src/app/lazy/modal-example/modal-example.component.ts @@ -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', @@ -30,7 +32,7 @@ export class ModalExampleComponent implements OnInit, ViewWillLeave, ViewDidEnte ) {} ngOnInit() { - NgZone.assertInAngularZone(); + assertZoneContext(); this.onInit++; } @@ -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() { diff --git a/packages/angular/test/base/src/app/lazy/modal-inline/modal-inline.component.ts b/packages/angular/test/base/src/app/lazy/modal-inline/modal-inline.component.ts index aba3f1a0139..6517bf94ba3 100644 --- a/packages/angular/test/base/src/app/lazy/modal-inline/modal-inline.component.ts +++ b/packages/angular/test/base/src/app/lazy/modal-inline/modal-inline.component.ts @@ -1,4 +1,4 @@ -import { AfterViewInit, Component } from "@angular/core"; +import { AfterViewInit, ChangeDetectorRef, Component } from "@angular/core"; /** * Validates that inline modals will correctly display @@ -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); } diff --git a/packages/angular/test/base/src/app/lazy/modal/modal.component.ts b/packages/angular/test/base/src/app/lazy/modal/modal.component.ts index d7e08342b40..ebbf1df5728 100644 --- a/packages/angular/test/base/src/app/lazy/modal/modal.component.ts +++ b/packages/angular/test/base/src/app/lazy/modal/modal.component.ts @@ -1,8 +1,10 @@ -import { Component, NgZone } from '@angular/core'; +import { ChangeDetectorRef, Component } from '@angular/core'; import { ModalController } from '@ionic/angular'; import { ModalExampleComponent } from '../modal-example/modal-example.component'; import { NavComponent } from '../nav/nav.component'; +import { assertZoneContext } from '../../zone-assert.util'; + @Component({ selector: 'app-modal', templateUrl: './modal.component.html', @@ -14,7 +16,8 @@ export class ModalComponent { onDidDismiss = false; constructor( - private modalCtrl: ModalController + private modalCtrl: ModalController, + private cdr: ChangeDetectorRef ) { } async openModal() { @@ -36,15 +39,19 @@ export class ModalComponent { }); await modal.present(); modal.onWillDismiss().then(() => { - NgZone.assertInAngularZone(); + assertZoneContext(); this.onWillDismiss = true; + // 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(); }); modal.onDidDismiss().then(() => { - NgZone.assertInAngularZone(); + assertZoneContext(); if (!this.onWillDismiss) { throw new Error('onWillDismiss should be emitted first'); } this.onDidDismiss = true; + // 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(); }); } } diff --git a/packages/angular/test/base/src/app/lazy/nested-outlet-page/nested-outlet-page.component.ts b/packages/angular/test/base/src/app/lazy/nested-outlet-page/nested-outlet-page.component.ts index eeb7547dbb2..a31bb3a8a8a 100644 --- a/packages/angular/test/base/src/app/lazy/nested-outlet-page/nested-outlet-page.component.ts +++ b/packages/angular/test/base/src/app/lazy/nested-outlet-page/nested-outlet-page.component.ts @@ -1,6 +1,8 @@ -import { Component, NgZone, OnDestroy, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { IonRouterOutlet } from '@ionic/angular'; +import { assertZoneContext } from '../../zone-assert.util'; + @Component({ selector: 'app-nested-outlet-page', templateUrl: './nested-outlet-page.component.html', @@ -14,10 +16,10 @@ export class NestedOutletPageComponent implements OnDestroy, OnInit { } ngOnInit() { - NgZone.assertInAngularZone(); + assertZoneContext(); } ngOnDestroy() { - NgZone.assertInAngularZone(); + assertZoneContext(); } } diff --git a/packages/angular/test/base/src/app/lazy/nested-outlet-page2/nested-outlet-page2.component.ts b/packages/angular/test/base/src/app/lazy/nested-outlet-page2/nested-outlet-page2.component.ts index 8c18bce1549..dfa1be5d75b 100644 --- a/packages/angular/test/base/src/app/lazy/nested-outlet-page2/nested-outlet-page2.component.ts +++ b/packages/angular/test/base/src/app/lazy/nested-outlet-page2/nested-outlet-page2.component.ts @@ -1,4 +1,6 @@ -import { Component, NgZone, OnDestroy, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; + +import { assertZoneContext } from '../../zone-assert.util'; @Component({ selector: 'app-nested-outlet-page2', @@ -8,10 +10,10 @@ import { Component, NgZone, OnDestroy, OnInit } from '@angular/core'; export class NestedOutletPage2Component implements OnDestroy, OnInit { ngOnInit() { - NgZone.assertInAngularZone(); + assertZoneContext(); } ngOnDestroy() { - NgZone.assertInAngularZone(); + assertZoneContext(); } } diff --git a/packages/angular/test/base/src/app/lazy/popover-inline/popover-inline.component.ts b/packages/angular/test/base/src/app/lazy/popover-inline/popover-inline.component.ts index f202057f0ad..83b2806c990 100644 --- a/packages/angular/test/base/src/app/lazy/popover-inline/popover-inline.component.ts +++ b/packages/angular/test/base/src/app/lazy/popover-inline/popover-inline.component.ts @@ -1,4 +1,4 @@ -import { Component } from "@angular/core"; +import { ChangeDetectorRef, Component } from "@angular/core"; import { IonPopover } from "@ionic/angular"; @@ -16,11 +16,15 @@ export class PopoverInlineComponent { items: {text: string, disabled?: boolean}[] = []; + constructor(private cdr: ChangeDetectorRef) {} + openPopover(popover: IonPopover) { popover.present(); setTimeout(() => { this.items = [{text: 'A'}, {text: 'B'}, {text: 'C', disabled: true}, {text: '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); } diff --git a/packages/angular/test/base/src/app/lazy/providers/providers.component.ts b/packages/angular/test/base/src/app/lazy/providers/providers.component.ts index 9ff1e480253..4ce9b4477de 100644 --- a/packages/angular/test/base/src/app/lazy/providers/providers.component.ts +++ b/packages/angular/test/base/src/app/lazy/providers/providers.component.ts @@ -1,4 +1,4 @@ -import { Component, NgZone } from '@angular/core'; +import { ChangeDetectorRef, Component, NgZone } from '@angular/core'; import { Platform, ModalController, @@ -13,6 +13,8 @@ import { Config, } from '@ionic/angular'; +import { assertZoneContext } from '../../zone-assert.util'; + @Component({ selector: 'app-providers', templateUrl: './providers.component.html', @@ -43,7 +45,8 @@ export class ProvidersComponent { navCtrl: NavController, domCtrl: DomController, config: Config, - zone: NgZone + zone: NgZone, + private cdr: ChangeDetectorRef ) { // test all providers load if ( @@ -64,23 +67,31 @@ export class ProvidersComponent { // test platform ready() platform.ready().then(() => { - NgZone.assertInAngularZone(); + assertZoneContext(); this.isReady = true; + // 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(); }); platform.resume.subscribe(() => { console.log('platform:resume'); - NgZone.assertInAngularZone(); + assertZoneContext(); this.isResumed = true; + // 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(); }); platform.pause.subscribe(() => { console.log('platform:pause'); - NgZone.assertInAngularZone(); + assertZoneContext(); this.isPaused = true; + // 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(); }); platform.resize.subscribe(() => { console.log('platform:resize'); - NgZone.assertInAngularZone(); + assertZoneContext(); this.isResized = true; + // 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(); }); const firstQuery = platform.getQueryParam('firstParam'); const secondQuery = platform.getQueryParam('secondParam'); @@ -103,6 +114,8 @@ export class ProvidersComponent { async setMenuCount() { const menus = await this.menuCtrl.getMenus(); this.registeredMenuCount = menus.length; + // 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(); } async openActionSheet() { diff --git a/packages/angular/test/base/src/app/lazy/router-link-page/router-link-page.component.ts b/packages/angular/test/base/src/app/lazy/router-link-page/router-link-page.component.ts index d6da25d7fc0..66d2cd3b1ec 100644 --- a/packages/angular/test/base/src/app/lazy/router-link-page/router-link-page.component.ts +++ b/packages/angular/test/base/src/app/lazy/router-link-page/router-link-page.component.ts @@ -1,6 +1,8 @@ -import { Component, OnInit, NgZone } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { IonRouterOutlet, ViewDidEnter, ViewDidLeave, ViewWillLeave } from '@ionic/angular'; +import { assertZoneContext } from '../../zone-assert.util'; + @Component({ selector: 'app-router-link-page', templateUrl: './router-link-page.component.html', @@ -20,7 +22,7 @@ export class RouterLinkPageComponent implements OnInit, ViewWillLeave, ViewDidEn ) {} ngOnInit() { - NgZone.assertInAngularZone(); + assertZoneContext(); this.canGoBack = this.ionRouterOutlet.canGoBack(); this.onInit++; } @@ -32,22 +34,22 @@ export class RouterLinkPageComponent implements OnInit, ViewWillLeave, ViewDidEn if (this.canGoBack !== this.ionRouterOutlet.canGoBack()) { throw new Error('canGoBack() changed'); } - NgZone.assertInAngularZone(); + assertZoneContext(); this.willEnter++; } ionViewDidEnter() { if (this.canGoBack !== this.ionRouterOutlet.canGoBack()) { throw new Error('canGoBack() changed'); } - NgZone.assertInAngularZone(); + assertZoneContext(); this.didEnter++; } ionViewWillLeave() { - NgZone.assertInAngularZone(); + assertZoneContext(); this.willLeave++; } ionViewDidLeave() { - NgZone.assertInAngularZone(); + assertZoneContext(); this.didLeave++; } } diff --git a/packages/angular/test/base/src/app/lazy/router-link/router-link.component.ts b/packages/angular/test/base/src/app/lazy/router-link/router-link.component.ts index 11e82b63f5d..20ca5790db9 100644 --- a/packages/angular/test/base/src/app/lazy/router-link/router-link.component.ts +++ b/packages/angular/test/base/src/app/lazy/router-link/router-link.component.ts @@ -1,7 +1,9 @@ -import { Component, NgZone, OnInit } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { NavController, ViewDidEnter, ViewDidLeave, ViewWillEnter, ViewWillLeave } from '@ionic/angular'; import { Router } from '@angular/router'; +import { assertZoneContext } from '../../zone-assert.util'; + @Component({ selector: 'app-router-link', templateUrl: './router-link.component.html', @@ -43,7 +45,7 @@ export class RouterLinkComponent implements OnInit, ViewWillEnter, ViewDidEnter, } ngOnInit() { - NgZone.assertInAngularZone(); + assertZoneContext(); this.onInit++; } @@ -51,19 +53,19 @@ export class RouterLinkComponent implements OnInit, ViewWillEnter, ViewDidEnter, 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++; } } diff --git a/packages/angular/test/base/src/app/lazy/tabs-tab1/tabs-tab1.component.ts b/packages/angular/test/base/src/app/lazy/tabs-tab1/tabs-tab1.component.ts index e133c393207..9cd7d44f57f 100644 --- a/packages/angular/test/base/src/app/lazy/tabs-tab1/tabs-tab1.component.ts +++ b/packages/angular/test/base/src/app/lazy/tabs-tab1/tabs-tab1.component.ts @@ -1,6 +1,8 @@ -import { Component, NgZone } from '@angular/core'; +import { ChangeDetectorRef, Component } from '@angular/core'; import { NavController } from '@ionic/angular'; +import { assertZoneContext } from '../../zone-assert.util'; + @Component({ selector: 'app-tabs-tab1', templateUrl: './tabs-tab1.component.html', @@ -11,13 +13,15 @@ export class TabsTab1Component { segment = 'one'; changed = 'false'; - constructor(public navCtrl: NavController) {} + constructor(public navCtrl: NavController, private cdr: ChangeDetectorRef) {} ionViewWillEnter() { - NgZone.assertInAngularZone(); + assertZoneContext(); setTimeout(() => { - NgZone.assertInAngularZone(); + assertZoneContext(); this.title = 'Tab 1 - Page 1'; + // 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(); }); } diff --git a/packages/angular/test/base/src/app/lazy/tabs-tab2/tabs-tab2.component.ts b/packages/angular/test/base/src/app/lazy/tabs-tab2/tabs-tab2.component.ts index 8922fd80732..60d64517e60 100644 --- a/packages/angular/test/base/src/app/lazy/tabs-tab2/tabs-tab2.component.ts +++ b/packages/angular/test/base/src/app/lazy/tabs-tab2/tabs-tab2.component.ts @@ -1,4 +1,6 @@ -import { Component, NgZone, OnInit } from '@angular/core'; +import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; + +import { assertZoneContext } from '../../zone-assert.util'; @Component({ selector: 'app-tabs-tab2', @@ -10,11 +12,15 @@ export class TabsTab2Component implements OnInit { segment = 'two'; changed = 'false'; + constructor(private cdr: ChangeDetectorRef) {} + ngOnInit() { - NgZone.assertInAngularZone(); + assertZoneContext(); setTimeout(() => { - NgZone.assertInAngularZone(); + assertZoneContext(); this.title = 'Tab 2 - Page 1'; + // 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(); }); } diff --git a/packages/angular/test/base/src/app/lazy/virtual-scroll-detail/virtual-scroll-detail.component.ts b/packages/angular/test/base/src/app/lazy/virtual-scroll-detail/virtual-scroll-detail.component.ts index bd8d6e6c220..7b362c74d79 100644 --- a/packages/angular/test/base/src/app/lazy/virtual-scroll-detail/virtual-scroll-detail.component.ts +++ b/packages/angular/test/base/src/app/lazy/virtual-scroll-detail/virtual-scroll-detail.component.ts @@ -1,7 +1,9 @@ -import { Component, NgZone, OnInit } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { ViewDidEnter, ViewDidLeave, ViewWillEnter, ViewWillLeave } from '@ionic/angular'; +import { assertZoneContext } from '../../zone-assert.util'; + @Component({ selector: 'app-virtual-scroll-detail', templateUrl: './virtual-scroll-detail.component.html', @@ -20,7 +22,7 @@ export class VirtualScrollDetailComponent implements OnInit, ViewWillEnter, View ngOnInit() { this.itemNu = this.route.snapshot.paramMap.get('itemId'); - NgZone.assertInAngularZone(); + assertZoneContext(); this.onInit++; } @@ -28,19 +30,19 @@ export class VirtualScrollDetailComponent implements OnInit, ViewWillEnter, View 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++; } } diff --git a/packages/angular/test/base/src/app/lazy/virtual-scroll-inner/virtual-scroll-inner.component.ts b/packages/angular/test/base/src/app/lazy/virtual-scroll-inner/virtual-scroll-inner.component.ts index ec1930b688d..063353d6738 100644 --- a/packages/angular/test/base/src/app/lazy/virtual-scroll-inner/virtual-scroll-inner.component.ts +++ b/packages/angular/test/base/src/app/lazy/virtual-scroll-inner/virtual-scroll-inner.component.ts @@ -1,4 +1,6 @@ -import { Component, OnInit, NgZone, Input } from '@angular/core'; +import { Component, OnInit, Input } from '@angular/core'; + +import { assertZoneContext } from '../../zone-assert.util'; @Component({ selector: 'app-virtual-scroll-inner', @@ -10,7 +12,7 @@ export class VirtualScrollInnerComponent implements OnInit { onInit = 0; ngOnInit() { - NgZone.assertInAngularZone(); + assertZoneContext(); this.onInit++; console.log('created'); } diff --git a/packages/angular/test/base/src/app/standalone/menu-controller/menu-controller.component.ts b/packages/angular/test/base/src/app/standalone/menu-controller/menu-controller.component.ts index d99944f5adc..bc2f637cd7e 100644 --- a/packages/angular/test/base/src/app/standalone/menu-controller/menu-controller.component.ts +++ b/packages/angular/test/base/src/app/standalone/menu-controller/menu-controller.component.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { ChangeDetectorRef, Component } from '@angular/core'; import { MenuController, IonMenu } from '@ionic/angular/standalone'; @Component({ @@ -10,10 +10,12 @@ import { MenuController, IonMenu } from '@ionic/angular/standalone'; export class MenuControllerComponent { registeredMenuCount = 0; - constructor(private menuCtrl: MenuController) {} + constructor(private menuCtrl: MenuController, private cdr: ChangeDetectorRef) {} async setMenuCount() { const menus = await this.menuCtrl.getMenus(); this.registeredMenuCount = menus.length; + // 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(); } } diff --git a/packages/angular/test/base/src/app/zone-assert.util.ts b/packages/angular/test/base/src/app/zone-assert.util.ts new file mode 100644 index 00000000000..1bb82a6850d --- /dev/null +++ b/packages/angular/test/base/src/app/zone-assert.util.ts @@ -0,0 +1,15 @@ +import { NgZone } from '@angular/core'; + +/** + * Asserts that the caller is running inside the Angular zone, but ONLY when the + * app is using Zone.js. Ionic 9 defaults to zoneless change detection, where + * there is no Angular zone to assert (and `NgZone.assertInAngularZone()` would + * throw). The version test apps split by Angular version: ng18-20 run with + * Zone.js and still verify the in-zone contract here, while ng21 runs zoneless + * and skips the assertion. + */ +export function assertZoneContext(): void { + if (typeof (window as any).Zone !== 'undefined') { + NgZone.assertInAngularZone(); + } +} From 47146ceba20ff266f5b8aa0d6e74d8e163d4f795 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Fri, 5 Jun 2026 14:38:16 -0700 Subject: [PATCH 2/2] docs(breaking): cleanup --- BREAKING.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/BREAKING.md b/BREAKING.md index 08f2754cd44..c44e248b38f 100644 --- a/BREAKING.md +++ b/BREAKING.md @@ -175,10 +175,6 @@ Angular forbids `provideZoneChangeDetection()` inside an NgModule's `providers` import 'zone.js'; ``` -**`bindLifecycleEvents` No Longer Exported** - -The internal `bindLifecycleEvents` helper is no longer exported from `@ionic/angular/common`. It was framework plumbing for wiring Ionic lifecycle events to component instances and was never part of the documented API. Apps don't call it directly. - **TypeScript** Ionic 9 supports TypeScript 5.4 or later, matching the minimum for Angular 18. Angular 21 requires TypeScript 5.9 or later per Angular's own requirements.