Skip to content

Commit a1eb6a1

Browse files
authored
feat(angular): default to zoneless change detection (#31196)
Issue number: resolves #30804 --------- ## What is the current behavior? Currently, on `major-9.0`, `@ionic/angular` still defaulted to Zone.js. The `ng add` schematic registered `provideZoneChangeDetection()`, and `zone.js` was a required peer dependency on both `@ionic/angular` and `@ionic/angular-server`. Angular 21 bootstraps zoneless out of the box, so Ionic was actively opting new apps back into Zone.js. ## What is the new behavior? Ionic 9 now defaults to zoneless change detection while keeping Zone.js as a supported opt-in. The `ng add` schematic no longer registers `provideZoneChangeDetection()`, and `zone.js` is now an optional peer dependency on both `@ionic/angular` and `@ionic/angular-server`. ## Does this introduce a breaking change? - [X] Yes - [ ] No Apps that want to keep Zone.js on Angular 21 must opt back in with `provideZoneChangeDetection()` AND keep `import 'zone.js'` in their polyfills (Angular 21's default scaffold omits it). Under zoneless, state updated from async callbacks Angular doesn't wrap needs a signal or a `markForCheck()` call. Template bindings, `@HostListener`, reactive forms, and synchronous lifecycle-hook state are unaffected. Angular 18 through 20 keep Angular's Zone.js default and need no change. BREAKING.md is updated with the full migration path. ## Other information - Test apps run dual-mode for coverage: ng21 is zoneless, ng18-20 stay on Zone.js. Shared `base/` test pages use the new `assertZoneContext()` helper (`base/src/app/zone-assert.util.ts`), which asserts in-zone only when Zone.js is present, so the same pages pass in both modes. Current dev build (2026-06-08): ``` 8.8.9-dev.11781013468.1ed93da9 ``` Test app: https://ionic-framework-git-feat-zoneless-ionic1.vercel.app/angular/ Docs PR: ionic-team/ionic-docs#4541 Docs Preview URL: https://ionic-docs-git-feat-zoneless-v2-ionic1.vercel.app/docs/updating/9-0#zoneless-change-detection
1 parent ba27083 commit a1eb6a1

36 files changed

Lines changed: 300 additions & 113 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ core/www/
6868
# playwright
6969
core/test-results/
7070
core/playwright-report/
71+
packages/angular/test-results/
7172

7273
# ground truths generated outside of docker should not be committed to the repo
7374
core/**/*-snapshots/*

BREAKING.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,17 @@ Apps that relied on `ionChange` firing on every confirmation (for example, to de
128128

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

131-
**Angular 21 Requires Explicit Zone Change Detection**
131+
**Zoneless Change Detection by Default**
132132

133-
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.
133+
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()`.
134+
135+
Because Zone.js no longer triggers change detection automatically, component state that you update from an asynchronous callback that 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.
136+
137+
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).
138+
139+
**Keeping Zone.js on Angular 21 (optional)**
140+
141+
To keep using Zone.js on Angular 21, opt back in with `provideZoneChangeDetection()` and keep `zone.js` in your polyfills.
134142

135143
Standalone bootstrap:
136144

@@ -160,7 +168,12 @@ NgModule bootstrap:
160168
.catch((err) => console.error(err));
161169
```
162170

163-
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.
171+
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:
172+
173+
```ts
174+
// src/polyfills.ts
175+
import 'zone.js';
176+
```
164177

165178
**TypeScript**
166179

core/src/components/modal/modal.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
439439
*/
440440
@Event() ionDragEnd!: EventEmitter<ModalDragEventDetail>;
441441

442+
@Watch('breakpoints')
442443
breakpointsChanged(breakpoints: number[] | undefined) {
443444
if (breakpoints !== undefined) {
444445
this.sortedBreakpoints = breakpoints.sort((a, b) => a - b);

core/src/components/modal/test/sheet/modal.e2e.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,4 +399,51 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
399399
expect(Object.keys(dragEndEvent.detail).length).toBe(5);
400400
});
401401
});
402+
403+
test.describe(title('sheet modal: late breakpoints binding'), () => {
404+
test('should not crash when swiped after breakpoints are set after the modal loads', async ({ page }) => {
405+
const pageErrors: string[] = [];
406+
page.on('pageerror', (err) => pageErrors.push(err.message));
407+
408+
await page.setContent(
409+
`
410+
<ion-modal initial-breakpoint="1">
411+
<ion-content>Modal Content</ion-content>
412+
</ion-modal>
413+
`,
414+
config
415+
);
416+
417+
const modal = page.locator('ion-modal');
418+
419+
/**
420+
* Simulates a JS framework (e.g. Angular with zoneless change detection)
421+
* applying the `breakpoints` binding after the web component has finished
422+
* loading. `setContent` resolves after `componentDidLoad`, so this lands
423+
* too late for the manual `breakpointsChanged()` call in `componentDidLoad`
424+
* to pick it up
425+
*/
426+
await modal.evaluate((el: HTMLIonModalElement) => {
427+
el.breakpoints = [0, 1];
428+
});
429+
430+
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
431+
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
432+
433+
await modal.evaluate((el: HTMLIonModalElement) => el.present());
434+
await ionModalDidPresent.next();
435+
436+
// Swiping the sheet down should snap it to breakpoint 0 and dismiss it
437+
// without throwing an error
438+
const handle = page.locator('ion-modal .modal-handle');
439+
await expect(handle).toBeVisible();
440+
await dragElementBy(handle, page, 0, 600);
441+
442+
// Flush any pending errors from the gesture's end handler
443+
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
444+
expect(pageErrors).toEqual([]);
445+
446+
await ionModalDidDismiss.next();
447+
});
448+
});
402449
});

packages/angular-server/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@
4141
"rxjs": ">=7.5.0",
4242
"zone.js": ">=0.13.0"
4343
},
44+
"peerDependenciesMeta": {
45+
"zone.js": {
46+
"optional": true
47+
}
48+
},
4449
"devDependencies": {
4550
"@angular/animations": "^21.0.0",
4651
"@angular/common": "^21.0.0",

packages/angular/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ This guide shows you how to test the local Ionic Framework build with a new Angu
7272
```sh
7373
# Change to whichever directory you want the app in
7474
cd ~/Documents/
75-
ng new my-app --style=css --ssr=false --zoneless=false
75+
ng new my-app --style=css --ssr=false
7676
cd my-app
7777
```
7878

packages/angular/common/src/directives/navigation/stack-controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export class StackController {
4141
createView(ref: ComponentRef<any>, activatedRoute: ActivatedRoute): RouteView {
4242
const url = getUrl(this.router, activatedRoute);
4343
const element = ref?.location?.nativeElement as HTMLElement;
44-
const unlistenEvents = bindLifecycleEvents(this.zone, ref.instance, element);
44+
const unlistenEvents = bindLifecycleEvents(this.zone, ref.changeDetectorRef, ref.instance, element);
4545
return {
4646
id: this.nextId++,
4747
stackId: computeStackId(this.tabsPrefix, url),

packages/angular/common/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export { NavController } from './providers/nav-controller';
55
export { Config, ConfigToken } from './providers/config';
66
export { Platform } from './providers/platform';
77

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

1010
export type { IonicWindow } from './types/interfaces';
1111
export type { ViewDidEnter, ViewDidLeave, ViewWillEnter, ViewWillLeave } from './types/ionic-lifecycle-hooks';

packages/angular/common/src/providers/angular-delegate.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
ApplicationRef,
3+
ChangeDetectorRef,
34
ComponentRef,
45
createComponent,
56
EnvironmentInjector,
@@ -228,11 +229,21 @@ export const attachView = (
228229
hostElement.classList.add(cssClass);
229230
}
230231
}
231-
const unbindEvents = bindLifecycleEvents(zone, instance, hostElement);
232+
const unbindEvents = bindLifecycleEvents(zone, componentRef.changeDetectorRef, instance, hostElement);
232233
container.appendChild(hostElement);
233234

234235
applicationRef.attachView(componentRef.hostView);
235236

237+
/**
238+
* Run change detection on the freshly attached view so Angular's init hooks
239+
* (`ngOnInit`, `ngAfterViewInit`) fire before the web component dispatches its
240+
* Ionic lifecycle events (`ionViewWillEnter`, etc.). `createComponent` only runs
241+
* the creation pass. The init hooks run during an update pass. Under Zone.js an
242+
* implicit tick used to cover this, but zoneless Angular schedules no such tick,
243+
* so without this the first `ionViewWillEnter` could run before `ngOnInit`.
244+
*/
245+
componentRef.changeDetectorRef.detectChanges();
246+
236247
elRefMap.set(hostElement, componentRef);
237248
elEventsMap.set(hostElement, unbindEvents);
238249
return hostElement;
@@ -246,10 +257,29 @@ const LIFECYCLES = [
246257
LIFECYCLE_WILL_UNLOAD,
247258
];
248259

249-
export const bindLifecycleEvents = (zone: NgZone, instance: any, element: HTMLElement): (() => void) => {
260+
export const bindLifecycleEvents = (
261+
zone: NgZone,
262+
changeDetectorRef: ChangeDetectorRef,
263+
instance: any,
264+
element: HTMLElement
265+
): (() => void) => {
266+
/**
267+
* `zone.run` keeps the listener registration (and, under Zone.js, the handler
268+
* execution) inside the Angular zone, so async work started inside a lifecycle
269+
* hook is still zone-tracked. Under zoneless Angular it is a passthrough.
270+
*/
250271
return zone.run(() => {
251272
const unregisters = LIFECYCLES.filter((eventName) => typeof instance[eventName] === 'function').map((eventName) => {
252-
const handler = (ev: any) => instance[eventName](ev.detail);
273+
const handler = (ev: any) => {
274+
instance[eventName](ev.detail);
275+
/**
276+
* Ionic lifecycle events (`ionViewWillEnter`, etc.) are dispatched from
277+
* the web component via a native event listener, so under zoneless
278+
* Angular nothing schedules change detection for state the hook mutates.
279+
* Mark the view dirty explicitly. This is a no-op-or-better under Zone.js.
280+
*/
281+
changeDetectorRef.markForCheck();
282+
};
253283
element.addEventListener(eventName, handler);
254284
return () => element.removeEventListener(eventName, handler);
255285
});

packages/angular/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@
6060
"rxjs": ">=7.5.0",
6161
"zone.js": ">=0.13.0"
6262
},
63+
"peerDependenciesMeta": {
64+
"zone.js": {
65+
"optional": true
66+
}
67+
},
6368
"devDependencies": {
6469
"@angular-devkit/core": "^21.0.0",
6570
"@angular-devkit/schematics": "^21.0.0",

0 commit comments

Comments
 (0)