Search code examples
angularevent-bindingmat-dialogng-containerng-component-outlet

Angular 11+ MatDialog: inner component's (ngComponentOutlet) html does not trigger <button (click)="close()"> method. Works on Angular 9


In fact, I'm having more issues with the ngComponentOutlet-embedded component inside the MatDialog. But let's start here.

What I'm building

I want to display an arbitrary Component inside a MatDialog. I've found a way, but while it works on Angular 9 (the version I've found an example written in), it does not work in Angular 11 (the version my project is based on) nor on Angular 13 (@latest).

Observations

  • when an inner HTML contains a <button (click)="close()">Close</button> and I click on the button, the inner Component's close() method is not triggered
  • it triggers the close() method if I bind it to the (mousedown) event instead of (click); probably works with other events but the (click) one
  • when I click on the button, instead the inner component is reloaded (see console logs in examples)
  • when I click anywhere on the dialog, the inner component is reloaded (see console logs in examples); does not happen in Angular 9

Angular 9 does not have this problem. I am using exactly the same app code in both examples below (both projects created with ng new, using different ng versions).

Repro examples

(stackblitz is ill, give it a few retries if it sneezes out 500s. Probably covid...)

Broken example (Angular 11)

Working example (Angular 9)

  • In the Angular 9 example, the MatDialog works as expected
  • In the Angular 11 example the MatDialog does not work as expected
  • I have tried Angular 13 (@latest), the problem persists

Questions

  1. Why is this happening?
  2. How do I get around this?

Raw files FFR

app.module.ts

import {NgModule} from '@angular/core';
import {BrowserModule} from '@angular/platform-browser';

import {AppComponent} from './app.component';
import {MatDialogModule} from '@angular/material/dialog';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {BaseDialogComponent, SampleInnerComponent} from './my-dialog.service';

@NgModule({
  declarations: [
    AppComponent,
    BaseDialogComponent, SampleInnerComponent
  ],
  imports: [
    BrowserModule,
    MatDialogModule, BrowserAnimationsModule
  ],
  exports: [BaseDialogComponent, SampleInnerComponent],
  providers: [BaseDialogComponent, SampleInnerComponent],
  bootstrap: [AppComponent],
  entryComponents: [BaseDialogComponent, SampleInnerComponent]
})
export class AppModule { }

app.component.ts

import {Component} from '@angular/core';
import {MyDialogService} from './my-dialog.service';
import {MatDialogRef} from '@angular/material/dialog';

@Component({
  selector: 'app-root',
  template: `
    <button (click)="toggle()">TOGGLE</button>
  `,
})
export class AppComponent {
  title = 'repro-broken';
  private dialogRef: MatDialogRef<any>;

  constructor(private dialogService: MyDialogService) {
  }

  toggle(): void {
    if (this.dialogRef) {
      this.dialogRef.close(undefined);
      this.dialogRef = undefined;
    } else {
      this.dialogRef = this.dialogService.open();
    }
  }
}

my-dialog.service.ts

import {MAT_DIALOG_DATA, MatDialog, MatDialogRef} from '@angular/material/dialog';
import {Component, Inject, Injectable, Injector} from '@angular/core';
import {ReplaySubject} from 'rxjs';
import {tap} from 'rxjs/operators';

@Injectable({
  providedIn: 'root'
})
export class MyDialogService {

  constructor(private dialog: MatDialog) {
  }

  open(): MatDialogRef<any> {
    const innerComp = new InjectedDialogRef();
    const dialogRef = this.dialog.open(BaseDialogComponent, {
      // width: '',
      // height: '',
      // closeOnNavigation: false,
      // disableClose: true,
      // backdropClass: [],
      // hasBackdrop: false,
      data: {component: SampleInnerComponent, data: innerComp}
    });

    innerComp.dialog$.next(dialogRef);
    return dialogRef;
  }

}


@Injectable()
export class InjectedDialogRef {
  dialog$ = new ReplaySubject<MatDialogRef<any>>(1);
}

@Component({
  selector: 'app-dialog-sample',
  template: `
    <div (mousedown)="stuff()">Dialog Inner Component</div>
    <button (click)="close()">Close</button>
    <!--    <button (click)="stuff()">Stuff</button>-->
  `,
})
export class SampleInnerComponent {
  public dialog: MatDialogRef<any>;

  constructor(private inj: InjectedDialogRef) {
    inj.dialog$
      .pipe(tap(evt => console.log('Got a dialog', evt)))
      .subscribe(dialog => this.dialog = dialog);
  }

  close(): void {
    console.log('Closing the dialog', this.dialog);
    this.dialog.close(undefined);
  }

  stuff(): void {
    console.log('Doing stuff');
  }
}

@Component({
  selector: 'app-dialog-base',
  template: `
    <h2 mat-dialog-title>MyTitle</h2>
    <div mat-dialog-content>
      <ng-container *ngComponentOutlet="inner.component; injector:createInjector(inner.data)"></ng-container>
    </div>
  `,
})
export class BaseDialogComponent {

  constructor(
    @Inject(MAT_DIALOG_DATA) public inner: any,
    private inj: Injector) {
    console.log('Opening base dialog');
  }

  createInjector(inj: InjectedDialogRef): Injector {
    return Injector.create({
      providers: [{provide: InjectedDialogRef, useValue: inj}],
      parent: this.inj
    });
  }
}


Solution

  • Get rid of createInjector(inner.data) method call from the BaseDialogComponent template.

    Instead create the injector and store it within the BaseDialogComponent property. Then assign that property to *ngComponentOutlet injector.

    @Component({
      selector: 'app-dialog-base',
      template: `
        <h2 mat-dialog-title>MyTitle</h2>
        <div mat-dialog-content>
    
        <!-- Removed createInjector(inner.data) method call and replaced with contentInjector property  -->
          <ng-container *ngComponentOutlet="inner.component; injector:contentInjector"></ng-container>
    
        </div>
      `,
    })
    export class BaseDialogComponent implements OnInit {
      contentInjector!: Injector; // Defined property to hold the content injector
    
      constructor(
        @Inject(MAT_DIALOG_DATA) public inner: any,
        private inj: Injector
      ) {
        console.log('Opening base dialog');
      }
    
      // Created the injector within ngOnInit
      ngOnInit() {
        this.contentInjector = this.createInjector(this.inner.data);
      }
    
      createInjector(inj: InjectedDialogRef): Injector {
        return Injector.create({
          providers: [{ provide: InjectedDialogRef, useValue: inj }],
          parent: this.inj,
        });
      }
    }
    

    Stackblitz


    Why the same code worked in Angular 9, but not in Angular 11 and above?

    First of all the issue(varying behavior) is not due to the code within Angular framework, but due to some code within Angular Material.

    In Angular Material v11, the CDK overlay adds a click event listener on document body during capture phase. Hence whenever you clicked, the Change detection was triggered even before the click listener associated with the button got chance to execute, which is turn resulted into re-rendering of the view as createInjector() method always returned a new Injector instance when called.

    Due to the same reason you observed the below behavior of component being reloaded/rendered:

    when I click anywhere on the dialog, the inner component is reloaded (see console logs in examples); does not happen in Angular 9

    click event listener in Angular Material v11

    The Angular Material v9 doesn't include this click event listener code, hence the listener associated with the button executed and closed the dialog without causing any issue. The clicks within the overlay and not on "Close" button again didn't triggered any Change Detection, and hence no re-rendering happened.

    You can replicate the same behavior in your Angular 9 code by adding a listener as below:

    // AppComponent
    constructor(private dialogService: MyDialogService) {
      document.body.addEventListener('click', () => console.log('clicked'), true);
    }