Skip to main content

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.

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;
}
}
}

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:

  1. ModalAngularApp: Original basic implementation
  2. 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

AspectModalAngularApp (Original)DialogMicroAppComponent (Enhanced)
NgZone Handling❌ No zone management✅ Proper zone separation
Subscription Management❌ No unsubscribe logic✅ Proper subscription lifecycle
State Tracking❌ No mounting state flagsisAppMounted 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 TypeZone ContextReason
Subscription SetupngZone.run()Angular needs to track subscriptions
State UpdatesngZone.run()Angular change detection required
Error HandlingngZone.run()Angular needs to process errors
System.importrunOutsideAngular()Browser API, no Angular tracking needed
mountParcelrunOutsideAngular()Single-SPA operation with own zone management
UnmountrunOutsideAngular()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 Zone errors
  • 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.