ModalAngularApp vs DialogMicroAppComponent - Single-SPA Angular Integration
This document compares two approaches to handling single-spa micro-frontend integration in Angular Material dialogs and explains the transformation from a basic implementation to a zone-aware solution.
- Better Approach
- Old Approach
import { AfterViewInit, Component, ElementRef, Inject, OnDestroy, ViewChild, ViewEncapsulation, NgZone } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { CustomProps, Parcel, ParcelConfig } from 'single-spa';
import { Observable, from, lastValueFrom, Subscription } from 'rxjs';
import { tap, map } from 'rxjs/operators';
import { singleSpaPropsSubject } from '../../../single-spa/single-spa-props';
import { environment } from '../../../environments/environment';
declare global {
interface Window {
System: {
import: (app: string) => Promise<ParcelConfig>;
};
}
}
let mountParcel;
export const bootstrap = [
(props) => {
mountParcel = props.mountParcel;
return Promise.resolve();
}
];
@Component({
selector: 'app-modal-angular-container',
template: '<div #modalMicroAppPopupContainer></div>',
encapsulation: ViewEncapsulation.None
})
export class DialogMicroAppComponent implements AfterViewInit, OnDestroy {
@ViewChild('modalMicroAppPopupContainer', { static: true })
private microAppPopupContainer!: ElementRef;
private applicationName: string;
private customProperty: object;
private singleSpaSubscription?: Subscription;
private isAppMounted = false;
private parcel?: Parcel;
constructor(
public dialogRef: MatDialogRef<DialogMicroAppComponent>,
@Inject(MAT_DIALOG_DATA) public data: any,
private ngZone: NgZone
) {
this.customProperty = dialogRef;
this.applicationName = data.applicationName;
}
private appParcelMap: {
[appName: string]: Parcel
} = {};
ngAfterViewInit(): void {
const domElement = this.microAppPopupContainer.nativeElement;
console.log('single-spa : starting mount process for', this.applicationName);
// Run the subscription setup inside Angular zone
this.ngZone.run(() => {
// Subscribe to single-spa props
this.singleSpaSubscription = singleSpaPropsSubject.subscribe({
next: (props) => {
if (!this.isAppMounted && props?.mountParcel) {
console.log('single-spa : received props, mounting', this.applicationName);
this.mountApp(props, domElement);
}
},
error: (error) => {
console.error('single-spa : props subscription error', error);
}
});
});
// Fallback mounting - also run in zone
setTimeout(() => {
this.ngZone.run(() => {
if (!this.isAppMounted && typeof mountParcel !== 'undefined') {
console.log('single-spa : fallback mounting');
this.mountAppDirect(domElement);
}
});
}, 100);
}
private mountApp(props: any, domElement: HTMLElement): void {
if (this.isAppMounted || this.parcel) {
console.log('single-spa : app already mounted, skipping');
return;
}
console.log('single-spa : mounting with props for', this.applicationName);
// Run mount operation outside Angular zone to avoid zone conflicts
this.ngZone.runOutsideAngular(() => {
window.System.import(environment['forntend-apps-map'][this.applicationName])
.then((app: ParcelConfig<CustomProps>) => {
console.log('single-spa : app module loaded, creating parcel');
this.parcel = props.mountParcel(app, {
domElement,
...this.customProperty
});
this.appParcelMap[this.applicationName] = this.parcel;
this.isAppMounted = true;
console.log('single-spa : successfully mounted', this.applicationName);
})
.catch(error => {
this.ngZone.run(() => {
console.error('single-spa : mount error', error);
this.isAppMounted = false;
this.parcel = undefined;
});
});
});
}
private mountAppDirect(domElement: HTMLElement): void {
if (this.isAppMounted || this.parcel) {
console.log('single-spa : app already mounted, skipping direct mount');
return;
}
console.log('single-spa : direct mounting for', this.applicationName);
// Run mount operation outside Angular zone to avoid zone conflicts
this.ngZone.runOutsideAngular(() => {
window.System.import(environment['forntend-apps-map'][this.applicationName])
.then((app: ParcelConfig<CustomProps>) => {
console.log('single-spa : app module loaded for direct mount');
this.parcel = mountParcel(app, {
domElement,
...this.customProperty
});
this.appParcelMap[this.applicationName] = this.parcel;
this.isAppMounted = true;
console.log('single-spa : successfully direct mounted', this.applicationName);
})
.catch(error => {
this.ngZone.run(() => {
console.error('single-spa : direct mount error', error);
this.isAppMounted = false;
this.parcel = undefined;
});
});
});
}
async ngOnDestroy() {
console.log('single-spa : ngOnDestroy called for', this.applicationName);
// Unsubscribe from single-spa props first - do this in Angular zone
this.ngZone.run(() => {
if (this.singleSpaSubscription) {
this.singleSpaSubscription.unsubscribe();
this.singleSpaSubscription = undefined;
console.log('single-spa : unsubscribed from props');
}
});
// Clean up parcel if it exists - do this outside Angular zone
if (this.parcel && this.isAppMounted) {
try {
console.log('single-spa : starting unmount for', this.applicationName);
await this.ngZone.runOutsideAngular(async () => {
await this.parcel!.unmount();
});
console.log('single-spa : unmount completed for', this.applicationName);
} catch (error) {
this.ngZone.run(() => {
console.error('Error unmounting single-spa app:', error);
});
} finally {
// Always clean up state - do this in Angular zone
this.ngZone.run(() => {
this.parcel = undefined;
delete this.appParcelMap[this.applicationName];
this.isAppMounted = false;
});
}
} else {
console.log('single-spa : no app to unmount for', this.applicationName);
// Ensure state is clean
this.isAppMounted = false;
this.parcel = undefined;
}
}
}
import { AfterViewInit, Component, ElementRef, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { CustomProps, Parcel, ParcelConfig } from 'single-spa';
import { Observable, from, lastValueFrom } from 'rxjs';
import { tap, map } from 'rxjs/operators';
// import { mapTo } from 'rxjs/operators'; // mapTo is deprecated, use map() instead
import { singleSpaPropsSubject } from '../../../../../single-spa/single-spa-props';
import { environment } from '../../../../../environments/environment';
declare global {
interface Window {
System: {
import: (app: string) => Promise<ParcelConfig>;
};
}
}
@Component({
selector: 'app-modal-angular-container',
template: `<div #modalMicroAppPopupContainer></div>`,
})
export class ModalAngularApp implements OnInit, AfterViewInit, OnDestroy{
@ViewChild('modalMicroAppPopupContainer', { static: true })
private microAppPopupContainer!: ElementRef;
private applicationName: string ;
private customProperty: object;
constructor(public dialogRef: MatDialogRef<ModalAngularApp>,
@Inject(MAT_DIALOG_DATA) public data: any) {
this.customProperty = dialogRef;
this.applicationName = data.applicationName;
}
private appParcelMap: {
[appName: string]: Parcel
} = {};
ngOnInit(): void { }
ngAfterViewInit(): void {
const domElement = this.microAppPopupContainer.nativeElement;
singleSpaPropsSubject.subscribe(props => {
window.System.import(environment['sspa-apps-map'][this.applicationName])
.then((app: ParcelConfig<CustomProps> ) => {
this.appParcelMap[this.applicationName] = props.mountParcel(app,
{
domElement,
...this.customProperty
});
});
});
}
async ngOnDestroy() {
// await this.unmountApp(this.applicationName).toPromise();
// toPromise() is deprecated, use lastValueFrom()/firstValueFrom() instead
await lastValueFrom(this.unmountApp(this.applicationName));
}
unmountApp(appName: string): Observable<void> {
return from(this.appParcelMap[appName].unmount()).pipe(
tap(() => delete this.appParcelMap[appName]),
// mapTo(null), // mapTo is deprecated, use map() instead
map(() => undefined)
);
}
}
Table of Contents
- Overview
- Original Implementation (ModalAngularApp)
- Zone-Aware Implementation (DialogMicroAppComponent)
- Key Differences
- Problems with Original Approach
- Zone Management Strategy
- Migration Guide
- Best Practices
Overview
This document analyzes two different approaches to integrating single-spa micro-frontends within Angular Material dialogs:
- ModalAngularApp: Original basic implementation
- DialogMicroAppComponent: Enhanced zone-aware implementation
The evolution from the first to the second addresses critical NgZone assertion errors and provides better reliability and performance.
Original Implementation (ModalAngularApp)
Basic Structure
@Component({
selector: 'app-modal-angular-container',
template: `<div #modalMicroAppPopupContainer></div>`,
})
export class ModalAngularApp implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('modalMicroAppPopupContainer', { static: true })
private microAppPopupContainer!: ElementRef;
private applicationName: string;
private customProperty: object;
private appParcelMap: { [appName: string]: Parcel } = {};
}
Simple Mounting Logic
ngAfterViewInit(): void {
const domElement = this.microAppPopupContainer.nativeElement;
singleSpaPropsSubject.subscribe(props => {
window.System.import(environment['sspa-apps-map'][this.applicationName])
.then((app: ParcelConfig<CustomProps>) => {
this.appParcelMap[this.applicationName] = props.mountParcel(app, {
domElement,
...this.customProperty
});
});
});
}
Basic Cleanup
async ngOnDestroy() {
await lastValueFrom(this.unmountApp(this.applicationName));
}
unmountApp(appName: string): Observable<void> {
return from(this.appParcelMap[appName].unmount()).pipe(
tap(() => delete this.appParcelMap[appName]),
map(() => undefined)
);
}
Zone-Aware Implementation (DialogMicroAppComponent)
Enhanced Structure
@Component({
selector: 'app-dialog-micro-app',
template: '<div #modalMicroAppPopupContainer></div>',
encapsulation: ViewEncapsulation.None
})
export class DialogMicroAppComponent implements AfterViewInit, OnDestroy {
@ViewChild('modalMicroAppPopupContainer', { static: true })
private microAppPopupContainer!: ElementRef;
private applicationName: string;
private customProperty: object;
private singleSpaSubscription?: Subscription;
private isAppMounted = false;
private parcel?: Parcel;
private appParcelMap: { [appName: string]: Parcel } = {};
constructor(
public dialogRef: MatDialogRef<DialogMicroAppComponent>,
@Inject(MAT_DIALOG_DATA) public data: any,
private ngZone: NgZone // Added NgZone injection
) {}
}
Zone-Aware Mounting
ngAfterViewInit(): void {
const domElement = this.microAppPopupContainer.nativeElement;
// Primary mounting strategy - inside Angular zone
this.ngZone.run(() => {
this.singleSpaSubscription = singleSpaPropsSubject.subscribe({
next: (props) => {
if (!this.isAppMounted && props?.mountParcel) {
this.mountApp(props, domElement);
}
},
error: (error) => {
console.error('Props subscription error', error);
}
});
});
// Fallback strategy
setTimeout(() => {
this.ngZone.run(() => {
if (!this.isAppMounted && typeof mountParcel !== 'undefined') {
this.mountAppDirect(domElement);
}
});
}, 100);
}
private mountApp(props: any, domElement: HTMLElement): void {
if (this.isAppMounted || this.parcel) return;
// Mount outside Angular zone to avoid conflicts
this.ngZone.runOutsideAngular(() => {
window.System.import(environment['sspa-apps-map'][this.applicationName])
.then((app: ParcelConfig<CustomProps>) => {
this.parcel = props.mountParcel(app, {
domElement,
...this.customProperty
});
this.appParcelMap[this.applicationName] = this.parcel;
this.isAppMounted = true;
})
.catch(error => {
this.ngZone.run(() => {
console.error('Mount error', error);
this.isAppMounted = false;
this.parcel = undefined;
});
});
});
}
Comprehensive Cleanup
async ngOnDestroy() {
// Step 1: Unsubscribe in Angular zone
this.ngZone.run(() => {
if (this.singleSpaSubscription) {
this.singleSpaSubscription.unsubscribe();
this.singleSpaSubscription = undefined;
}
});
// Step 2: Unmount outside Angular zone
if (this.parcel && this.isAppMounted) {
try {
await this.ngZone.runOutsideAngular(async () => {
await this.parcel!.unmount();
});
} catch (error) {
this.ngZone.run(() => {
console.error('Error unmounting single-spa app:', error);
});
} finally {
// Step 3: Clean state in Angular zone
this.ngZone.run(() => {
this.parcel = undefined;
delete this.appParcelMap[this.applicationName];
this.isAppMounted = false;
});
}
}
}
Key Differences
| Aspect | ModalAngularApp (Original) | DialogMicroAppComponent (Enhanced) |
|---|---|---|
| NgZone Handling | ❌ No zone management |