feat(angular): add custom injector support for modal and popover controllers (#30899)

Issue number: resolves #30638

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
When using `ModalController.create()` or `PopoverController.create()` in
Angular, components rendered inside overlays cannot access non-global
services or tokens from the component tree. For example, route-scoped
services or Angular's Dir directive for bidirectional text support are
not accessible from within a modal, requiring complex workarounds with
wrapper components.

## What is the new behavior?
`ModalController.create()` and `PopoverController.create()` now accept
an optional injector property that allows passing a custom Angular
Injector. This enables overlay components to access services and tokens
that are not available in the root injector, such as route-scoped
services or the Dir directive from Angular CDK.

```typescript
const customInjector = Injector.create({
  providers: [{ provide: MyService, useValue: myServiceInstance }],
  parent: this.injector,
});
```

```typescript
const modal = await this.modalController.create({
  component: MyModalComponent,
  injector: customInjector,
});
```

## Does this introduce a breaking change?

- [ ] Yes
- [X] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

Current dev build:
```
8.7.17-dev.11769628168.11eca7cd
```
This commit is contained in:
Shane
2026-01-29 08:35:06 -08:00
committed by GitHub
parent 0cf4c03e29
commit 822da428af
19 changed files with 292 additions and 22 deletions

View File

@@ -9,6 +9,7 @@ export { AngularDelegate, bindLifecycleEvents, IonModalToken } from './providers
export type { IonicWindow } from './types/interfaces';
export type { ViewDidEnter, ViewDidLeave, ViewWillEnter, ViewWillLeave } from './types/ionic-lifecycle-hooks';
export type { ModalOptions, PopoverOptions } from './types/overlay-options';
export { NavParams } from './directives/navigation/nav-params';

View File

@@ -36,7 +36,8 @@ export class AngularDelegate {
create(
environmentInjector: EnvironmentInjector,
injector: Injector,
elementReferenceKey?: string
elementReferenceKey?: string,
customInjector?: Injector
): AngularFrameworkDelegate {
return new AngularFrameworkDelegate(
environmentInjector,
@@ -44,7 +45,8 @@ export class AngularDelegate {
this.applicationRef,
this.zone,
elementReferenceKey,
this.config.useSetInputAPI ?? false
this.config.useSetInputAPI ?? false,
customInjector
);
}
}
@@ -59,7 +61,8 @@ export class AngularFrameworkDelegate implements FrameworkDelegate {
private applicationRef: ApplicationRef,
private zone: NgZone,
private elementReferenceKey?: string,
private enableSignalsSupport?: boolean
private enableSignalsSupport?: boolean,
private customInjector?: Injector
) {}
attachViewToDom(container: any, component: any, params?: any, cssClasses?: string[]): Promise<any> {
@@ -93,7 +96,8 @@ export class AngularFrameworkDelegate implements FrameworkDelegate {
componentProps,
cssClasses,
this.elementReferenceKey,
this.enableSignalsSupport
this.enableSignalsSupport,
this.customInjector
);
resolve(el);
});
@@ -131,7 +135,8 @@ export const attachView = (
params: any,
cssClasses: string[] | undefined,
elementReferenceKey: string | undefined,
enableSignalsSupport: boolean | undefined
enableSignalsSupport: boolean | undefined,
customInjector?: Injector
): any => {
/**
* Wraps the injector with a custom injector that
@@ -158,7 +163,7 @@ export const attachView = (
const childInjector = Injector.create({
providers,
parent: injector,
parent: customInjector ?? injector,
});
const componentRef = createComponent<any>(component, {

View File

@@ -0,0 +1,18 @@
import type { Injector } from '@angular/core';
import type { ModalOptions as CoreModalOptions, PopoverOptions as CorePopoverOptions } from '@ionic/core/components';
/**
* Modal options with Angular-specific injector support.
* Extends @ionic/core ModalOptions with an optional injector property.
*/
export type ModalOptions = CoreModalOptions & {
injector?: Injector;
};
/**
* Popover options with Angular-specific injector support.
* Extends @ionic/core PopoverOptions with an optional injector property.
*/
export type PopoverOptions = CorePopoverOptions & {
injector?: Injector;
};

View File

@@ -32,6 +32,7 @@ export {
ViewDidEnter,
ViewDidLeave,
} from '@ionic/angular/common';
export type { ModalOptions, PopoverOptions } from '@ionic/angular/common';
export { AlertController } from './providers/alert-controller';
export { AnimationController } from './providers/animation-controller';
export { ActionSheetController } from './providers/action-sheet-controller';
@@ -98,14 +99,12 @@ export {
IonicSafeString,
LoadingOptions,
MenuCustomEvent,
ModalOptions,
NavCustomEvent,
PickerOptions,
PickerButton,
PickerColumn,
PickerColumnOption,
PlatformConfig,
PopoverOptions,
RadioGroupCustomEvent,
RadioGroupChangeEventDetail,
RangeCustomEvent,

View File

@@ -1,6 +1,6 @@
import { Injector, Injectable, EnvironmentInjector, inject } from '@angular/core';
import { AngularDelegate, OverlayBaseController } from '@ionic/angular/common';
import type { ModalOptions } from '@ionic/core';
import type { ModalOptions } from '@ionic/angular/common';
import { modalController } from '@ionic/core';
@Injectable()
@@ -14,9 +14,10 @@ export class ModalController extends OverlayBaseController<ModalOptions, HTMLIon
}
create(opts: ModalOptions): Promise<HTMLIonModalElement> {
const { injector: customInjector, ...restOpts } = opts;
return super.create({
...opts,
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'modal'),
...restOpts,
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'modal', customInjector),
});
}
}

View File

@@ -1,6 +1,6 @@
import { Injector, inject, EnvironmentInjector } from '@angular/core';
import { AngularDelegate, OverlayBaseController } from '@ionic/angular/common';
import type { PopoverOptions } from '@ionic/core';
import type { PopoverOptions } from '@ionic/angular/common';
import { popoverController } from '@ionic/core';
export class PopoverController extends OverlayBaseController<PopoverOptions, HTMLIonPopoverElement> {
@@ -13,9 +13,10 @@ export class PopoverController extends OverlayBaseController<PopoverOptions, HTM
}
create(opts: PopoverOptions): Promise<HTMLIonPopoverElement> {
const { injector: customInjector, ...restOpts } = opts;
return super.create({
...opts,
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'popover'),
...restOpts,
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'popover', customInjector),
});
}
}

View File

@@ -28,6 +28,7 @@ export {
ViewWillLeave,
ViewDidLeave,
} from '@ionic/angular/common';
export type { ModalOptions, PopoverOptions } from '@ionic/angular/common';
export { IonNav } from './navigation/nav';
export {
IonCheckbox,
@@ -96,14 +97,12 @@ export {
IonicSafeString,
LoadingOptions,
MenuCustomEvent,
ModalOptions,
NavCustomEvent,
PickerOptions,
PickerButton,
PickerColumn,
PickerColumnOption,
PlatformConfig,
PopoverOptions,
RadioGroupCustomEvent,
RadioGroupChangeEventDetail,
RangeCustomEvent,

View File

@@ -1,6 +1,6 @@
import { Injector, Injectable, EnvironmentInjector, inject } from '@angular/core';
import { AngularDelegate, OverlayBaseController } from '@ionic/angular/common';
import type { ModalOptions } from '@ionic/core/components';
import type { ModalOptions } from '@ionic/angular/common';
import { modalController } from '@ionic/core/components';
import { defineCustomElement } from '@ionic/core/components/ion-modal.js';
@@ -16,9 +16,10 @@ export class ModalController extends OverlayBaseController<ModalOptions, HTMLIon
}
create(opts: ModalOptions): Promise<HTMLIonModalElement> {
const { injector: customInjector, ...restOpts } = opts;
return super.create({
...opts,
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'modal'),
...restOpts,
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'modal', customInjector),
});
}
}

View File

@@ -1,6 +1,6 @@
import { Injector, inject, EnvironmentInjector } from '@angular/core';
import { AngularDelegate, OverlayBaseController } from '@ionic/angular/common';
import type { PopoverOptions } from '@ionic/core/components';
import type { PopoverOptions } from '@ionic/angular/common';
import { popoverController } from '@ionic/core/components';
import { defineCustomElement } from '@ionic/core/components/ion-popover.js';
@@ -15,9 +15,10 @@ export class PopoverController extends OverlayBaseController<PopoverOptions, HTM
}
create(opts: PopoverOptions): Promise<HTMLIonPopoverElement> {
const { injector: customInjector, ...restOpts } = opts;
return super.create({
...opts,
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'popover'),
...restOpts,
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'popover', customInjector),
});
}
}

View File

@@ -0,0 +1,30 @@
import { test, expect } from '@playwright/test';
test.describe('Modal: Custom Injector', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/standalone/modal-custom-injector');
});
test('should inject custom service via custom injector', async ({ page }) => {
await page.locator('ion-button#open-modal-with-custom-injector').click();
await expect(page.locator('ion-modal')).toBeVisible();
const serviceValue = page.locator('#service-value');
await expect(serviceValue).toHaveText('Service Value: custom-injector-value');
await page.locator('#close-modal').click();
await expect(page.locator('ion-modal')).not.toBeVisible();
});
test('should fail without custom injector when service is not globally provided', async ({ page }) => {
page.on('dialog', async (dialog) => {
expect(dialog.message()).toContain('TestService not available');
await dialog.accept();
});
await page.locator('ion-button#open-modal-without-custom-injector').click();
await page.waitForEvent('dialog');
});
});

View File

@@ -0,0 +1,16 @@
import { test, expect } from '@playwright/test';
test.describe('Popover: Custom Injector', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/standalone/popover-custom-injector');
});
test('should inject custom service via custom injector', async ({ page }) => {
await page.locator('ion-button#open-popover-with-custom-injector').click();
await expect(page.locator('ion-popover')).toBeVisible();
const serviceValue = page.locator('#service-value');
await expect(serviceValue).toHaveText('Service Value: custom-injector-value');
});
});

View File

@@ -22,6 +22,8 @@ export const routes: Routes = [
]
},
{ path: 'programmatic-modal', loadComponent: () => import('../programmatic-modal/programmatic-modal.component').then(c => c.ProgrammaticModalComponent) },
{ path: 'modal-custom-injector', loadComponent: () => import('../modal-custom-injector/modal-custom-injector.component').then(c => c.ModalCustomInjectorComponent) },
{ path: 'popover-custom-injector', loadComponent: () => import('../popover-custom-injector/popover-custom-injector.component').then(c => c.PopoverCustomInjectorComponent) },
{ path: 'router-outlet', loadComponent: () => import('../router-outlet/router-outlet.component').then(c => c.RouterOutletComponent) },
{ path: 'back-button', loadComponent: () => import('../back-button/back-button.component').then(c => c.BackButtonComponent) },
{ path: 'router-link', loadComponent: () => import('../router-link/router-link.component').then(c => c.RouterLinkComponent) },

View File

@@ -110,6 +110,11 @@
Programmatic Modal Test
</ion-label>
</ion-item>
<ion-item routerLink="/standalone/modal-custom-injector">
<ion-label>
Modal Custom Injector Test
</ion-label>
</ion-item>
<ion-item routerLink="/standalone/overlay-controllers">
<ion-label>
Overlay Controllers Test
@@ -120,6 +125,11 @@
Popover Test
</ion-label>
</ion-item>
<ion-item routerLink="/standalone/popover-custom-injector">
<ion-label>
Popover Custom Injector Test
</ion-label>
</ion-item>
</ion-list>
<ion-list>

View File

@@ -0,0 +1,57 @@
import { Component, inject, Injector } from '@angular/core';
import { IonContent, IonHeader, IonTitle, IonToolbar, IonButton, ModalController } from '@ionic/angular/standalone';
import { ModalCustomInjectorModalComponent } from './modal/modal.component';
import { TestService } from './test.service';
@Component({
selector: 'app-modal-custom-injector',
template: `
<ion-header>
<ion-toolbar>
<ion-title>Modal Custom Injector Test</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-button id="open-modal-with-custom-injector" (click)="openWithCustomInjector()">
Open Modal with Custom Injector
</ion-button>
<ion-button id="open-modal-without-custom-injector" (click)="openWithoutCustomInjector()">
Open Modal without Custom Injector
</ion-button>
</ion-content>
`,
standalone: true,
imports: [IonContent, IonHeader, IonTitle, IonToolbar, IonButton]
})
export class ModalCustomInjectorComponent {
private modalController = inject(ModalController);
private injector = inject(Injector);
async openWithCustomInjector() {
const testService = new TestService();
testService.setValue('custom-injector-value');
const customInjector = Injector.create({
providers: [{ provide: TestService, useValue: testService }],
parent: this.injector,
});
const modal = await this.modalController.create({
component: ModalCustomInjectorModalComponent,
injector: customInjector,
});
await modal.present();
}
async openWithoutCustomInjector() {
try {
const modal = await this.modalController.create({
component: ModalCustomInjectorModalComponent,
});
await modal.present();
} catch (e) {
alert('Error: TestService not available without custom injector');
}
}
}

View File

@@ -0,0 +1,35 @@
import { Component, OnInit, inject } from '@angular/core';
import { IonContent, IonHeader, IonTitle, IonToolbar, IonButton, IonButtons } from '@ionic/angular/standalone';
import { TestService } from '../test.service';
@Component({
selector: 'app-modal-custom-injector-modal',
template: `
<ion-header>
<ion-toolbar>
<ion-title>Modal with Custom Injector</ion-title>
<ion-buttons slot="end">
<ion-button id="close-modal" (click)="dismiss()">Close</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<p id="service-value">Service Value: {{ serviceValue }}</p>
</ion-content>
`,
standalone: true,
imports: [IonContent, IonHeader, IonTitle, IonToolbar, IonButton, IonButtons]
})
export class ModalCustomInjectorModalComponent implements OnInit {
private testService = inject(TestService);
serviceValue = '';
modal: HTMLIonModalElement | undefined;
ngOnInit() {
this.serviceValue = this.testService.getValue();
}
dismiss() {
this.modal?.dismiss();
}
}

View File

@@ -0,0 +1,14 @@
import { Injectable } from '@angular/core';
@Injectable()
export class TestService {
private value = 'default-value';
setValue(value: string) {
this.value = value;
}
getValue(): string {
return this.value;
}
}

View File

@@ -0,0 +1,44 @@
import { Component, inject, Injector } from '@angular/core';
import { IonContent, IonHeader, IonTitle, IonToolbar, IonButton, PopoverController } from '@ionic/angular/standalone';
import { PopoverCustomInjectorPopoverComponent } from './popover/popover.component';
import { TestService } from './test.service';
@Component({
selector: 'app-popover-custom-injector',
template: `
<ion-header>
<ion-toolbar>
<ion-title>Popover Custom Injector Test</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-button id="open-popover-with-custom-injector" (click)="openWithCustomInjector($event)">
Open Popover with Custom Injector
</ion-button>
</ion-content>
`,
standalone: true,
imports: [IonContent, IonHeader, IonTitle, IonToolbar, IonButton]
})
export class PopoverCustomInjectorComponent {
private popoverController = inject(PopoverController);
private injector = inject(Injector);
async openWithCustomInjector(event: Event) {
const testService = new TestService();
testService.setValue('custom-injector-value');
const customInjector = Injector.create({
providers: [{ provide: TestService, useValue: testService }],
parent: this.injector,
});
const popover = await this.popoverController.create({
component: PopoverCustomInjectorPopoverComponent,
event: event,
injector: customInjector,
});
await popover.present();
}
}

View File

@@ -0,0 +1,22 @@
import { Component, OnInit, inject } from '@angular/core';
import { IonContent } from '@ionic/angular/standalone';
import { TestService } from '../test.service';
@Component({
selector: 'app-popover-custom-injector-popover',
template: `
<ion-content>
<p id="service-value">Service Value: {{ serviceValue }}</p>
</ion-content>
`,
standalone: true,
imports: [IonContent]
})
export class PopoverCustomInjectorPopoverComponent implements OnInit {
private testService = inject(TestService);
serviceValue = '';
ngOnInit() {
this.serviceValue = this.testService.getValue();
}
}

View File

@@ -0,0 +1,14 @@
import { Injectable } from '@angular/core';
@Injectable()
export class TestService {
private value = 'default-value';
setValue(value: string) {
this.value = value;
}
getValue(): string {
return this.value;
}
}