0

I am creating dynamic components using Angular Material Portal's ComponentPortal.

ngAfterViewInit() {
     this.userSettingsPortal = new ComponentPortal(UserSettingsComponent, null, this.hostInjector);
}

Then I'm displaying it like this:

<ng-template [cdkPortalOutlet]="userSettingsPortal"></ng-template>

Let's say this generates me a UserSettingsComponent component and I want to apply a margin.

app-user-settings 
{
   margin: 20px;
   outline: 2px solid red;
}

This doesn't work unless I use ::ng-deep which is very clumsy. Typically ::ng-deep is used to 'pierce' an element's style black box but in this case I'm not doing that. I simply want the host component (of UserSettings) to position it but it can't.

Note: This isn't specific to portals - if I manually create the component it's still the same issue.

Simon_Weaver
  • 140,023
  • 84
  • 646
  • 689

1 Answers1

0

Note: There is an open issue related to this but it's been open for nearly 5 years.

The underlying problem is that the renderer doesn't apply the _ngcontent-app-c123 attribute to the dynamic component. Therefore you need ::ng-deep to avoid generating css that targets (and requires) that attribute selector.

Solution 1: Put a wrapper around it.

This is the obvious solution, but you end up with an extra wrapper.

<div id="usersettings">
   <ng-template [cdkPortalOutlet]="userSettingsPortal"></ng-template>
</div>

You can then style this div as you'd expect with #usersettings.

Solution 2: Custom directive to apply _ngcontent attribute.

If you know the portal is only going to be displayed in one place (not moved around) then the following will work. Please note this answer is my own from the aforementioned issue.


I find using ComponentPortal the easiest and best way to generate dynamic components, and you can then attach them easily to an ng-template element. If you're not already using it I recommend it for simplicity.

Creating a ComponentPortal is quite easy:

ngAfterViewInit() {
    this.userSettingsPortal = new ComponentPortal(UserSettingsComponent);
}

Then you render it like this:

<ng-template [cdkPortalOutlet]="userSettingsPortal"></ng-template>

You can inject dependencies by the mechanism described here. (Note for > Angular 10 you shouldn't use the deprecated answer with WeakMap).

Important Design / Injector Tree considerations

You may just be creating one dynamic component, or you may be creating a whole tree. In either case you really need to pass the injector of your host component into the ComponentPortal constructor to get the 'normal' behavior you'd expect in a component injector tree.

Oddly the example shown above (from CDK docs) doesn't do this. I think the reason is one of the primary uses for portals is to take a component defined one place and put it on your page wherever you want. So in that case a parent injector makes less sense.

If you're generating a component dynamically and placing it in the same component you really should be using the following constructor:

     const componentPortal = new ComponentPortal(component, null, parentInjector);

However if you're creating a tree of dynamic components this becomes a logistical pain! You have to clutter up your host components with all this parentInjector code.

My solution to ViewEncapsulation.Emulated issue

My application is a graphical UI to design a page from components like grids, tables, images, video etc.

The model is defined as a tree of 'rendered nodes' something like the following. As you can see I have a ComponentPortal in each node:

export type RenderedPage =
{
    children: (RenderedPageNode | undefined)[];
}

// this corresponds to a node in the tree
export type RenderedPageNode =
{
    portal: ComponentPortal;
    children: RenderedPageNode[] | undefined;
}

BTW. This model is displayed by a component that iterates through children and recursively calls itself to stamp out the tree. Basically it's an *ngFor loop of ng-template [cdkPortalOutlet]="node.portal".

I started by (naively) creating all the ComponentPortal for the tree eagerly. The problem with this way is that the correct component instance injector isn't available at the time I create the tree. When you create a ComponentPortal your component is not actually instantiated. This means that component injected services - notably Renderer2 aren't the one you actually would want. In fact when I tried @SkipSelf() private renderer2: Renderer2 it would jump all the way to the outermost dynamic component.

So I realized I'd need to avoid creating the component portal until the actual host component was being 'run':

This is what the original attempt looked like (with eagerly created portalInstances):

    <ng-template [cdkPortalOutlet]="pagenode.portalInstance"></ng-template>

Then I realized I could just make my own portal directive to do exactly what I wanted and more!

    <ng-template [dynamicComponentOutlet]="pagenode"></ng-template>

Note how I pass in the node and not the portal instance.

So what this directive will do is:

  • Take a prerendered pagenode representing the dynamic component's definition only (and its children)
  • The first time it tries to attach the portal it'll actaually create the ComponentPortal instance with the correct parent Injector
  • Because the injector context of the dynamicComponentOutlet outlet is the host component it can also generate and apply the _ngcontent-app-c338 attribute (which is the whole issue this issue is about!).

Here's my solution:

  1. First I needed to create a LazyComponentOutlet which contains a placeholder for the ComponentPortal and also any data needed to create it. I've just called this params because it will be up to you. I'm also not including the ComponentPortalParams definition for the same reason. At minimum it would need to include the component type.
// this corresponds to a node in the tree
export type RenderedPageNode =
{
    // lazily instantiated portal
    lazyPortal: LazyComponentPortal;
    children: RenderedPageNode[] | undefined;
}

export type LazyComponentPortal =
{
    // the actual ComponentPortal which initially is undefined until the directive initializes it
    componentPortal: ComponentPortal<any> | undefined;

    // whatever we need to create a component
    params: ComponentPortalParams   // this is application specific to whatever you need 
}

Then the DynamicComponentPortalHost attribute (rename this however you please):

Note this is inspired by the way they do portal inheritance in portal-directives.ts

@Directive({
    selector: '[dynamicComponentOutlet]',
    exportAs: 'rrDynamicComponentHost',
    inputs: ['dynamicComponentOutlet: rrDynamicComponentHost'],
    providers: [{
        provide: CdkPortalOutlet,
        useExisting: DynamicComponentPortalHostDirective
    }]
})
export class DynamicComponentPortalHostDirective extends CdkPortalOutlet {

    constructor( 
        
        // parameters required by CdkPortalOutlet constructor (passed via super)
        _componentFactoryResolver: ComponentFactoryResolver,
        _viewContainerRef: ViewContainerRef,
        @Inject(DOCUMENT) _document: any,
        
        // renderer inherited from host component (where the ng-template is defined)
        private renderer2: Renderer2,
        
        // injector (from parent) to use as a parent injector for our ComponentPortal
        private injector: Injector,

        // my own service to create a ComponentPortal
        // it's up to you how you create a ComponentPortal inside this
        private componentPortalFactory: ComponentPortalFactoryService)
    {
        super(_componentFactoryResolver, _viewContainerRef, _document);

        // need to subscribe immediately because ngOnInit is too late
        // when the component is attached we can immediately grab its element 
        this._subscription.add(this.attached.subscribe((component: ComponentRef<any> | null) => {

            if (component)  
            {
                // use parent renderer to determine the correct content attribute for us
                // to do this we just render a fake element and 'borrow' it's first (and only) attribute
                // _ngcontent-app-c338
                const contentAttr = this.renderer2.createElement('div').attributes[0].name;
                renderer2.setAttribute(component.location.nativeElement, contentAttr, '');
            }
        }));
    }

    _subscription = new Subscription()

    ngOnDestroy()
    {
        this._subscription.unsubscribe();
    }

    @Input('dynamicComponentOutlet')
    set dynamicComponentOutlet(pageNode: RenderedPageNode) 
    {
        // if we haven't yet instantiated a ComponentPortal instance create one
        if (!pageNode.lazyPortal.componentPortal)
        {
            // create component portal
            // how you do this is up to you, just be sure to use the constructor that includes injector
            const componentPortal = this.componentPortalFactory.createComponentPortal(pageNode.lazyPortal.params, this.injector);

            // we now have an actual instance of ComponentPortal, so save a reference
            value.portal.componentPortal = componentPortal;
        }

        // set the ComponentPortal on the actual 'inherited cdkPortal'
        this.portal = value.portal.componentPortal!;
    }
}

Finally this method works just as well for a single item and not a tree. Or you could extract just the part that renders the ngContent attribute if you can't shoehorn this into an existing project.

And that's it! Of course if they fix (or change encapsulation) this in future you've only got to update this in one place.

Simon_Weaver
  • 140,023
  • 84
  • 646
  • 689