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 | ✅ Proper zone separation |
| Subscription Management | ❌ No unsubscribe logic | ✅ Proper subscription lifecycle |
| State Tracking | ❌ No mounting state flags | ✅ isAppMounted and parcel tracking |
| Error Handling | ❌ Basic promise catch | ✅ Comprehensive error recovery |
| Mounting Strategy | ❌ Single strategy | ✅ Dual strategy with fallback |
| Cleanup | ❌ Basic unmount only | ✅ Complete lifecycle cleanup |
| Memory Management | ❌ Potential memory leaks | ✅ Proper memory cleanup |
| Debugging | ❌ No logging | ✅ Detailed console logging |
Problems with Original Approach
1. NgZone Assertion Errors
// ❌ Problem: All operations run in Angular zone
singleSpaPropsSubject.subscribe(props => {
window.System.import(...) // Triggers Angular change detection
.then((app) => {
props.mountParcel(app, ...); // Also triggers change detection
});
});
Result: NG0909: Expected to be in Angular Zone, but it is not!
2. Memory Leaks
// ❌ Problem: No subscription cleanup
ngAfterViewInit(): void {
singleSpaPropsSubject.subscribe(props => {
// Subscription never unsubscribed
});
}
Result: Memory leaks and potential multiple subscriptions
3. No State Management
// ❌ Problem: No mounting state tracking
ngAfterViewInit(): void {
singleSpaPropsSubject.subscribe(props => {
// Could mount multiple times
this.appParcelMap[this.applicationName] = props.mountParcel(app, ...);
});
}
Result: Duplicate mounting attempts and undefined behavior
4. Poor Error Handling
// ❌ Problem: No error handling in subscription
singleSpaPropsSubject.subscribe(props => {
window.System.import(...)
.then((app) => {
// No error handling here
});
// No error callback for subscription
});
Result: Silent failures and broken state
Zone Management Strategy
Understanding the Problem
Angular Zone: Patches async operations to trigger change detection Single-SPA: Has its own lifecycle and zone management
When these two systems interact, conflicts arise:
// ❌ Problem: Zone conflict
Angular Zone monitors → window.System.import() → Single-SPA operations
↓
Triggers Angular change detection
↓
Conflicts with Single-SPA's own zone management
↓
NG0909: Expected to be in Angular Zone error
The Solution: Zone Separation
// ✅ Solution: Proper zone separation
// Angular operations (subscriptions, state) → Angular Zone
this.ngZone.run(() => {
this.singleSpaSubscription = singleSpaPropsSubject.subscribe(...);
this.isAppMounted = false;
});
// Single-SPA operations (loading, mounting) → Outside Angular Zone
this.ngZone.runOutsideAngular(() => {
window.System.import(...);
props.mountParcel(...);
});
Zone Context Rules
| Operation Type | Zone Context | Reason |
|---|---|---|
| Subscription Setup | ngZone.run() | Angular needs to track subscriptions |
| State Updates | ngZone.run() | Angular change detection required |
| Error Handling | ngZone.run() | Angular needs to process errors |
| System.import | runOutsideAngular() | Browser API, no Angular tracking needed |
| mountParcel | runOutsideAngular() | Single-SPA operation with own zone management |
| Unmount | runOutsideAngular() | Single-SPA operation with own zone management |
Migration Guide
Step 1: Add NgZone Injection
// Before
constructor(
public dialogRef: MatDialogRef<ModalAngularApp>,
@Inject(MAT_DIALOG_DATA) public data: any
) {}
// After
constructor(
public dialogRef: MatDialogRef<DialogMicroAppComponent>,
@Inject(MAT_DIALOG_DATA) public data: any,
private ngZone: NgZone // Add NgZone
) {}
Step 2: Add State Management Properties
// Add these properties
private singleSpaSubscription?: Subscription;
private isAppMounted = false;
private parcel?: Parcel;
Step 3: Update Mounting Logic
// Before
ngAfterViewInit(): void {
singleSpaPropsSubject.subscribe(props => {
window.System.import(...).then(...);
});
}
// After
ngAfterViewInit(): void {
this.ngZone.run(() => {
this.singleSpaSubscription = singleSpaPropsSubject.subscribe({
next: (props) => {
if (!this.isAppMounted && props?.mountParcel) {
this.mountApp(props, domElement);
}
}
});
});
}
private mountApp(props: any, domElement: HTMLElement): void {
this.ngZone.runOutsideAngular(() => {
window.System.import(...).then(...);
});
}
Step 4: Update Cleanup Logic
// Before
async ngOnDestroy() {
await lastValueFrom(this.unmountApp(this.applicationName));
}
// After
async ngOnDestroy() {
this.ngZone.run(() => {
if (this.singleSpaSubscription) {
this.singleSpaSubscription.unsubscribe();
}
});
if (this.parcel) {
await this.ngZone.runOutsideAngular(async () => {
await this.parcel!.unmount();
});
}
}
Best Practices
1. Zone Management
✅ DO: Separate Angular and Single-SPA operations
// Angular operations
this.ngZone.run(() => {
this.subscription = observable.subscribe(...);
});
// Single-SPA operations
this.ngZone.runOutsideAngular(() => {
window.System.import(...);
});
❌ DON'T: Mix zone contexts
// Don't do this
this.ngZone.run(() => {
window.System.import(...); // Single-SPA operation in Angular zone
});
2. State Management
✅ DO: Track mounting state
if (!this.isAppMounted && this.parcel) {
// Mount logic
}
❌ DON'T: Allow duplicate mounting
// Always mount without checking
props.mountParcel(app, ...);
3. Error Handling
✅ DO: Handle errors in appropriate zones
this.ngZone.runOutsideAngular(() => {
window.System.import(...)
.catch(error => {
this.ngZone.run(() => {
// Handle error in Angular zone
console.error(error);
this.isAppMounted = false;
});
});
});
4. Subscription Management
✅ DO: Always unsubscribe
ngOnDestroy() {
if (this.singleSpaSubscription) {
this.singleSpaSubscription.unsubscribe();
}
}
5. Debugging
✅ DO: Add comprehensive logging
console.log('Component: operation started', this.applicationName);
console.log('Component: operation completed', this.applicationName);
console.error('Component: operation failed', error);
Benefits of Enhanced Approach
✅ Eliminates NgZone Errors
- No more
NG0909: Expected to be in Angular Zoneerrors - Proper separation of Angular and Single-SPA zone contexts
- Maintains Angular change detection integrity
✅ Prevents Memory Leaks
- Proper subscription management
- Clean component destruction
- No hanging references
✅ Reliable Mounting
- Dual mounting strategy with fallback
- State management prevents duplicate mounting
- Works consistently on multiple dialog opens
✅ Better Error Recovery
- Comprehensive error handling
- State cleanup on failures
- Detailed logging for debugging
✅ Improved Performance
- Angular doesn't track Single-SPA operations unnecessarily
- Reduced change detection cycles
- Better resource management
This enhanced approach provides a robust, production-ready solution for integrating Single-SPA micro-frontends within Angular applications while avoiding common pitfalls and zone-related issues.