Files
ionic-framework/packages/angular/common/src/providers/angular-delegate.ts
Shane 822da428af 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
```
2026-01-29 08:35:06 -08:00

279 lines
7.9 KiB
TypeScript

import {
ApplicationRef,
ComponentRef,
createComponent,
EnvironmentInjector,
inject,
Injectable,
InjectionToken,
Injector,
NgZone,
} from '@angular/core';
import {
FrameworkDelegate,
LIFECYCLE_DID_ENTER,
LIFECYCLE_DID_LEAVE,
LIFECYCLE_WILL_ENTER,
LIFECYCLE_WILL_LEAVE,
LIFECYCLE_WILL_UNLOAD,
} from '@ionic/core/components';
import { NavParams } from '../directives/navigation/nav-params';
import { ConfigToken } from './config';
// Token for injecting the modal element
export const IonModalToken = new InjectionToken<HTMLIonModalElement>('IonModalToken');
// TODO(FW-2827): types
@Injectable()
export class AngularDelegate {
private zone = inject(NgZone);
private applicationRef = inject(ApplicationRef);
private config = inject(ConfigToken);
create(
environmentInjector: EnvironmentInjector,
injector: Injector,
elementReferenceKey?: string,
customInjector?: Injector
): AngularFrameworkDelegate {
return new AngularFrameworkDelegate(
environmentInjector,
injector,
this.applicationRef,
this.zone,
elementReferenceKey,
this.config.useSetInputAPI ?? false,
customInjector
);
}
}
export class AngularFrameworkDelegate implements FrameworkDelegate {
private elRefMap = new WeakMap<HTMLElement, ComponentRef<any>>();
private elEventsMap = new WeakMap<HTMLElement, () => void>();
constructor(
private environmentInjector: EnvironmentInjector,
private injector: Injector,
private applicationRef: ApplicationRef,
private zone: NgZone,
private elementReferenceKey?: string,
private enableSignalsSupport?: boolean,
private customInjector?: Injector
) {}
attachViewToDom(container: any, component: any, params?: any, cssClasses?: string[]): Promise<any> {
return this.zone.run(() => {
return new Promise((resolve) => {
const componentProps = {
...params,
};
/**
* Ionic Angular passes a reference to a modal
* or popover that can be accessed using a
* variable in the overlay component. If
* elementReferenceKey is defined, then we should
* pass a reference to the component using
* elementReferenceKey as the key.
*/
if (this.elementReferenceKey !== undefined) {
componentProps[this.elementReferenceKey] = container;
}
const el = attachView(
this.zone,
this.environmentInjector,
this.injector,
this.applicationRef,
this.elRefMap,
this.elEventsMap,
container,
component,
componentProps,
cssClasses,
this.elementReferenceKey,
this.enableSignalsSupport,
this.customInjector
);
resolve(el);
});
});
}
removeViewFromDom(_container: any, component: any): Promise<void> {
return this.zone.run(() => {
return new Promise((resolve) => {
const componentRef = this.elRefMap.get(component);
if (componentRef) {
componentRef.destroy();
this.elRefMap.delete(component);
const unbindEvents = this.elEventsMap.get(component);
if (unbindEvents) {
unbindEvents();
this.elEventsMap.delete(component);
}
}
resolve();
});
});
}
}
export const attachView = (
zone: NgZone,
environmentInjector: EnvironmentInjector,
injector: Injector,
applicationRef: ApplicationRef,
elRefMap: WeakMap<HTMLElement, ComponentRef<any>>,
elEventsMap: WeakMap<HTMLElement, () => void>,
container: any,
component: any,
params: any,
cssClasses: string[] | undefined,
elementReferenceKey: string | undefined,
enableSignalsSupport: boolean | undefined,
customInjector?: Injector
): any => {
/**
* Wraps the injector with a custom injector that
* provides NavParams to the component.
*
* NavParams is a legacy feature from Ionic v3 that allows
* Angular developers to provide data to a component
* and access it by providing NavParams as a dependency
* in the constructor.
*
* The modern approach is to access the data directly
* from the component's class instance.
*/
const providers = getProviders(params);
// If this is an ion-modal, provide the modal element as an injectable
// so components inside the modal can inject it directly
if (container.tagName.toLowerCase() === 'ion-modal') {
providers.push({
provide: IonModalToken,
useValue: container,
});
}
const childInjector = Injector.create({
providers,
parent: customInjector ?? injector,
});
const componentRef = createComponent<any>(component, {
environmentInjector,
elementInjector: childInjector,
});
const instance = componentRef.instance;
const hostElement = componentRef.location.nativeElement;
if (params) {
/**
* For modals and popovers, a reference to the component is
* added to `params` during the call to attachViewToDom. If
* a reference using this name is already set, this means
* the app is trying to use the name as a component prop,
* which will cause collisions.
*/
if (elementReferenceKey && instance[elementReferenceKey] !== undefined) {
console.error(
`[Ionic Error]: ${elementReferenceKey} is a reserved property when using ${container.tagName.toLowerCase()}. Rename or remove the "${elementReferenceKey}" property from ${
component.name
}.`
);
}
/**
* Angular 14.1 added support for setInput
* so we need to fall back to Object.assign
* for Angular 14.0.
*/
if (enableSignalsSupport === true && componentRef.setInput !== undefined) {
const { modal, popover, ...otherParams } = params;
/**
* Any key/value pairs set in componentProps
* must be set as inputs on the component instance.
*/
for (const key in otherParams) {
componentRef.setInput(key, otherParams[key]);
}
/**
* Using setInput will cause an error when
* setting modal/popover on a component that
* does not define them as an input. For backwards
* compatibility purposes we fall back to using
* Object.assign for these properties.
*/
if (modal !== undefined) {
Object.assign(instance, { modal });
}
if (popover !== undefined) {
Object.assign(instance, { popover });
}
} else {
Object.assign(instance, params);
}
}
if (cssClasses) {
for (const cssClass of cssClasses) {
hostElement.classList.add(cssClass);
}
}
const unbindEvents = bindLifecycleEvents(zone, instance, hostElement);
container.appendChild(hostElement);
applicationRef.attachView(componentRef.hostView);
elRefMap.set(hostElement, componentRef);
elEventsMap.set(hostElement, unbindEvents);
return hostElement;
};
const LIFECYCLES = [
LIFECYCLE_WILL_ENTER,
LIFECYCLE_DID_ENTER,
LIFECYCLE_WILL_LEAVE,
LIFECYCLE_DID_LEAVE,
LIFECYCLE_WILL_UNLOAD,
];
export const bindLifecycleEvents = (zone: NgZone, instance: any, element: HTMLElement): (() => void) => {
return zone.run(() => {
const unregisters = LIFECYCLES.filter((eventName) => typeof instance[eventName] === 'function').map((eventName) => {
const handler = (ev: any) => instance[eventName](ev.detail);
element.addEventListener(eventName, handler);
return () => element.removeEventListener(eventName, handler);
});
return () => unregisters.forEach((fn) => fn());
});
};
const NavParamsToken = new InjectionToken<any>('NavParamsToken');
const getProviders = (params: { [key: string]: any }) => {
return [
{
provide: NavParamsToken,
useValue: params,
},
{
provide: NavParams,
useFactory: provideNavParamsInjectable,
deps: [NavParamsToken],
},
];
};
const provideNavParamsInjectable = (params: { [key: string]: any }) => {
return new NavParams(params);
};