15

Question

I'm looking for the best approach for injecting a known/defined component into the root of an application and projecting @Input() options onto that component.

Requirement

This is necessary for creating things like modals/tooltips in the body of the application so that overflow:hidden/etc will not distort the position or cut it off completely.

Research

I've found that I can get the ApplicationRef's and then hackily traverse upwards and find the ViewContainerRef.

constructor(private applicationRef: ApplicationRef) {
}

getRootViewContainerRef(): ViewContainerRef {
  return this.applicationRef['_rootComponents'][0]['_hostElement'].vcRef;
}

once I have that I can then call createComponent on the ref like:

appendNextToLocation<T>(componentClass: Type<T>, location: ViewContainerRef): ComponentRef<T> {
  const componentFactory = this.componentFactoryResolver.resolveComponentFactory(componentClass);
  const parentInjector = location.parentInjector;
  return location.createComponent(componentFactory, location.length, parentInjector);
}

but now I've created the component but none of my Input properties are fulfilled. To achieve that I have to manually traverse over my options and set those on the result of appendNextToLocation's instance like:

const props = Object.getOwnPropertyNames(options);
for(const prop of props) {
  component.instance[prop] = options[prop];
}

now I do realize you could do some DI to inject the options but that makes it not re-usable when trying to use as a normal component then. Heres what that looks like for reference:

let componentFactory = this.componentFactoryResolver.resolveComponentFactory(ComponentClass);
let parentInjector = location.parentInjector;

let providers = ReflectiveInjector.resolve([
  { provide: ComponentOptionsClass, useValue: options }
]);

childInjector = ReflectiveInjector.fromResolvedProviders(providers, parentInjector);

return location.createComponent(componentFactory, location.length, childInjector);

all that said, all of the above actually works but it feels tad hacky at times. I'm also concerned about lifecycle timing of setting the input properties like the above since it happens after its created.

Notable References

amcdnl
  • 8,470
  • 12
  • 63
  • 99
  • 1
    You can't use bindings for dynamically added components. Your approach is the best you can currently get from Angular2. I think the Angular2 team will try to improve here but it's unclear what and when to expect it. – Günter Zöchbauer Oct 04 '16 at 16:19

2 Answers2

20

In 2.3.0, attachView was introduced which allows you to be able to attach change detection to the ApplicationRef, however, you still need to manually append the element to the root container. This is because with Angular2 the possibilities of environments its running could be web workers, universal, nativescript, etc so we need to explicitly tell it where/how we want to add this to the view.

Below is a sample service that will allow you to insert a component dynamically and project the Input's of the component automatically.

import {
  ApplicationRef, ComponentFactoryResolver, ComponentRef, Injectable,
  Injector, ViewContainerRef, EmbeddedViewRef, Type
} from '@angular/core';

/**
 * Injection service is a helper to append components
 * dynamically to a known location in the DOM, most
 * noteably for dialogs/tooltips appending to body.
 * 
 * @export
 * @class InjectionService
 */
@Injectable()
export class InjectionService {
  private _container: ComponentRef<any>;

  constructor(
    private applicationRef: ApplicationRef,
    private componentFactoryResolver: ComponentFactoryResolver,
    private injector: Injector) {
  }

  /**
   * Gets the root view container to inject the component to.
   * 
   * @returns {ComponentRef<any>}
   * 
   * @memberOf InjectionService
   */
  getRootViewContainer(): ComponentRef<any> {
    if(this._container) return this._container;

    const rootComponents = this.applicationRef['_rootComponents'];
    if (rootComponents.length) return rootComponents[0];

    throw new Error('View Container not found! ngUpgrade needs to manually set this via setRootViewContainer.');
  }

  /**
   * Overrides the default root view container. This is useful for 
   * things like ngUpgrade that doesn't have a ApplicationRef root.
   * 
   * @param {any} container
   * 
   * @memberOf InjectionService
   */
  setRootViewContainer(container): void {
    this._container = container;
  }

  /**
   * Gets the html element for a component ref.
   * 
   * @param {ComponentRef<any>} componentRef
   * @returns {HTMLElement}
   * 
   * @memberOf InjectionService
   */
  getComponentRootNode(componentRef: ComponentRef<any>): HTMLElement {
    return (componentRef.hostView as EmbeddedViewRef<any>).rootNodes[0] as HTMLElement;
  }

  /**
   * Gets the root component container html element.
   * 
   * @returns {HTMLElement}
   * 
   * @memberOf InjectionService
   */
  getRootViewContainerNode(): HTMLElement {
    return this.getComponentRootNode(this.getRootViewContainer());
  }

  /**
   * Projects the inputs onto the component
   * 
   * @param {ComponentRef<any>} component
   * @param {*} options
   * @returns {ComponentRef<any>}
   * 
   * @memberOf InjectionService
   */
  projectComponentInputs(component: ComponentRef<any>, options: any): ComponentRef<any> {
    if(options) {
      const props = Object.getOwnPropertyNames(options);
      for(const prop of props) {
        component.instance[prop] = options[prop];
      }
    }

    return component;
  }

  /**
   * Appends a component to a adjacent location
   * 
   * @template T
   * @param {Type<T>} componentClass
   * @param {*} [options={}]
   * @param {Element} [location=this.getRootViewContainerNode()]
   * @returns {ComponentRef<any>}
   * 
   * @memberOf InjectionService
   */
  appendComponent<T>(
    componentClass: Type<T>, 
    options: any = {}, 
    location: Element = this.getRootViewContainerNode()): ComponentRef<any> {

    let componentFactory = this.componentFactoryResolver.resolveComponentFactory(componentClass);
    let componentRef = componentFactory.create(this.injector);
    let appRef: any = this.applicationRef;
    let componentRootNode = this.getComponentRootNode(componentRef);

    // project the options passed to the component instance
    this.projectComponentInputs(componentRef, options);

    appRef.attachView(componentRef.hostView);

    componentRef.onDestroy(() => {
      appRef.detachView(componentRef.hostView);
    });

    location.appendChild(componentRootNode);

    return componentRef;
  }
}
amcdnl
  • 8,470
  • 12
  • 63
  • 99
  • according to description on [angular.io](https://angular.io/docs/ts/latest/api/core/index/ApplicationRef-class.html#!#attachView-anchor) for `attachView`, it says and I quote ''The view will be automatically detached when it is destroyed'' ... so I conclude that subscribing on `onDestroy` to detach the view is obsolete? – j3ff Apr 11 '17 at 15:33
  • amcdnl, could you kindly exemplify how to use the service above. I'm trying to add a component dynamically to app root, and observe changes in order to remove it dynamically (In a similar fashion to how *Angular Material 2* are adding the backdrop to dialogs and menus). Also, I can't find reference to `attachView()` in angular documentation. Has it been deprecated? – Asaf Agranat Aug 03 '17 at 14:36
  • @Rhumbus you should look into [cdk portals](https://medium.com/@Cgatian/angular-cdk-portals-b02f66dd020c) to simplify this – cgatian Oct 25 '17 at 00:12
  • @amcdnl just one question: when will the created component be cleared up. currently the componentRef.onDestroy event does not get called when I move to a different URL route... – baHI Apr 01 '18 at 16:23
  • You should clean it up manually. If you keep the componentRef inside another component, you can use `onDestroy()` in THAT component to do a manual `componentRef.destroy()`. – DarkNeuron May 01 '18 at 12:40
  • I have noticed that if I do add a
    and inside it I do create a component dynamically, then I do remove the component by setting *ngIf expression to false, then: the component does not get destroyed. Is there a way to get some notification if a dynamically injected component is removed from the DOM? I’m able to do a cleanup on navigation and so (when the main container which hold ComponentRef’s) is destroyed, but if someone would use a *ngIf on the page, without destroying the container, then I’d end up with a lot of from view removed, but not destroyed ComponentRefs.
    – baHI May 02 '18 at 13:58
  • 1
    can you update your script so it works under latest angular version? update 2020 :) – Honchar Denys May 21 '20 at 20:06
  • 1
    Found how to make it compatible with the latest angular. In the method `getRootViewContainer()` replace `const rootComponent` value with `this.applicationRef['_rootComponents'] || this.applicationRef['_views'];`. Also, replace `getComponentRootNode()` with this code `return componentRef.hostView ? (componentRef.hostView as EmbeddedViewRef).rootNodes[0] : (componentRef as any).rootNodes[0] as HTMLElement;` P.S. Tested it in the external library works like a charm. It might not be applicable for all – Andrey Seregin Jun 17 '21 at 11:08
1

getRootViewContainer needs to be modified as below for newer versions of Angular. Rest of it works like a charm.

getRootViewContainer(): ComponentRef<any> {
    if(this._container) return this._container;

    return (this.applicationRef.components[0].hostView as EmbeddedViewRef<any>).rootNodes[0] as HTMLElement;
}
sidthesloth
  • 1,399
  • 1
  • 11
  • 19
  • your code looks logical based on what we have now, but I am getting error: ```error TS2740: Type 'HTMLElement' is missing the following properties from type 'ComponentRef': location, injector, instance, hostView, and 4 more.``` – Honchar Denys May 20 '20 at 21:05
  • would be nice if you drop same file with the answer below, which works on your side with latest angular, thank you :) – Honchar Denys May 20 '20 at 21:21