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'sclose()
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...)
- 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
- Why is this happening?
- 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
});
}
}
Get rid of
createInjector(inner.data)
method call from theBaseDialogComponent
template.Instead create the injector and store it within the
BaseDialogComponent
property. Then assign that property to*ngComponentOutlet
injector.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 documentbody
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 ascreateInjector()
method always returned a new Injector instance when called.Due to the same reason you observed the below behavior of component being reloaded/rendered:
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: