diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 00000000..b386e130
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,5 @@
+{
+ "files.associations": {
+ "*.template": "ejs"
+ }
+}
diff --git a/apps/chat/src/main.single-spa.ts b/apps/chat/src/main.single-spa.ts
index e840c83c..6961460a 100644
--- a/apps/chat/src/main.single-spa.ts
+++ b/apps/chat/src/main.single-spa.ts
@@ -1,5 +1,5 @@
import { NavigationStart, Router } from '@angular/router';
-import { getSingleSpaExtraProviders, singleSpaAngular } from 'single-spa-angular';
+import { getSingleSpaExtraProviders, singleSpaAngular } from '@single-spa-community/angular';
import { singleSpaPropsSubject } from './single-spa/single-spa-props';
import { bootstrapApplication, platformBrowser } from '@angular/platform-browser';
diff --git a/apps/elements/src/main.single-spa.ts b/apps/elements/src/main.single-spa.ts
index 4c659831..26b1e1dc 100644
--- a/apps/elements/src/main.single-spa.ts
+++ b/apps/elements/src/main.single-spa.ts
@@ -1,5 +1,5 @@
-import { singleSpaAngularElements } from 'single-spa-angular/elements';
-import { getSingleSpaExtraProviders } from 'single-spa-angular';
+import { singleSpaAngularElements } from '@single-spa-community/angular/elements';
+import { getSingleSpaExtraProviders } from '@single-spa-community/angular';
import { AppModule } from './app/app.module';
diff --git a/apps/navbar/src/main.single-spa.ts b/apps/navbar/src/main.single-spa.ts
index 0f62a51e..859e1d50 100644
--- a/apps/navbar/src/main.single-spa.ts
+++ b/apps/navbar/src/main.single-spa.ts
@@ -1,6 +1,6 @@
import { NavigationStart, Router } from '@angular/router';
import { bootstrapApplication, platformBrowser } from '@angular/platform-browser';
-import { getSingleSpaExtraProviders, singleSpaAngular } from 'single-spa-angular';
+import { getSingleSpaExtraProviders, singleSpaAngular } from '@single-spa-community/angular';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
diff --git a/apps/parcel/src/app/app.component.html b/apps/parcel/src/app/app.component.html
index 20fc31de..58b448c5 100644
--- a/apps/parcel/src/app/app.component.html
+++ b/apps/parcel/src/app/app.component.html
@@ -1,4 +1,6 @@
@let _mountRootParcel = mountRootParcel();
+@let _customProps = customProps();
@if (_mountRootParcel) {
-
+
+
}
diff --git a/apps/parcel/src/app/app.component.ts b/apps/parcel/src/app/app.component.ts
index 8d79aa8e..473c4226 100644
--- a/apps/parcel/src/app/app.component.ts
+++ b/apps/parcel/src/app/app.component.ts
@@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { defer, shareReplay } from 'rxjs';
-import { ParcelModule } from 'single-spa-angular/parcel';
+import { ParcelModule } from '@single-spa-community/angular/parcel';
import { config } from './ReactWidget/ReactWidget';
@@ -16,15 +16,21 @@ const singleSpa$ = defer(() => System.import('single-spa')).pipe(
imports: [ParcelModule],
})
export class AppComponent {
- config = config;
+ readonly config = config;
readonly mountRootParcel = signal(null);
- customProps = {
+ readonly customProps = signal>({
hello: 'Hola',
- };
+ });
constructor() {
singleSpa$.pipe(takeUntilDestroyed()).subscribe(({ mountRootParcel }) => {
this.mountRootParcel.set(mountRootParcel);
});
}
+
+ updateCustomProps(): void {
+ this.customProps.set({
+ hello: 'Bonjour',
+ });
+ }
}
diff --git a/apps/parcel/src/main.single-spa.ts b/apps/parcel/src/main.single-spa.ts
index 7493cf69..064fb50e 100644
--- a/apps/parcel/src/main.single-spa.ts
+++ b/apps/parcel/src/main.single-spa.ts
@@ -1,5 +1,5 @@
import { bootstrapApplication, platformBrowser } from '@angular/platform-browser';
-import { getSingleSpaExtraProviders, singleSpaAngular } from 'single-spa-angular';
+import { getSingleSpaExtraProviders, singleSpaAngular } from '@single-spa-community/angular';
import { AppComponent } from './app/app.component';
diff --git a/apps/root-config/src/index.ejs b/apps/root-config/src/index.ejs
index 67ffa6c7..ec468c80 100644
--- a/apps/root-config/src/index.ejs
+++ b/apps/root-config/src/index.ejs
@@ -53,10 +53,10 @@
"@angular/elements": "http://localhost:4600/es2022/angular-elements.js",
- "single-spa-angular/internals": "http://localhost:4600/es2022/single-spa-angular-internals.js",
- "single-spa-angular": "http://localhost:4600/es2022/single-spa-angular.js",
- "single-spa-angular/elements": "http://localhost:4600/es2022/single-spa-angular-elements.js",
- "single-spa-angular/parcel": "http://localhost:4600/es2022/single-spa-angular-parcel.js"
+ "@single-spa-community/angular/internals": "http://localhost:4600/es2022/single-spa-community-angular-internals.js",
+ "@single-spa-community/angular": "http://localhost:4600/es2022/single-spa-community-angular.js",
+ "@single-spa-community/angular/elements": "http://localhost:4600/es2022/single-spa-community-angular-elements.js",
+ "@single-spa-community/angular/parcel": "http://localhost:4600/es2022/single-spa-community-angular-parcel.js"
}
}
@@ -87,10 +87,10 @@
"@angular/elements": "http://localhost:4600/es2022/angular-elements.min.js",
- "single-spa-angular/internals": "http://localhost:4600/es2022/single-spa-angular-internals.min.js",
- "single-spa-angular": "http://localhost:4600/es2022/single-spa-angular.min.js",
- "single-spa-angular/elements": "http://localhost:4600/es2022/single-spa-angular-elements.min.js",
- "single-spa-angular/parcel": "http://localhost:4600/es2022/single-spa-angular-parcel.min.js"
+ "@single-spa-community/angular/internals": "http://localhost:4600/es2022/single-spa-community-angular-internals.min.js",
+ "@single-spa-community/angular": "http://localhost:4600/es2022/single-spa-community-angular.min.js",
+ "@single-spa-community/angular/elements": "http://localhost:4600/es2022/single-spa-community-angular-elements.min.js",
+ "@single-spa-community/angular/parcel": "http://localhost:4600/es2022/single-spa-community-angular-parcel.min.js"
}
}
diff --git a/apps/shop/src/main.single-spa.ts b/apps/shop/src/main.single-spa.ts
index cb19380c..9f2ff13f 100644
--- a/apps/shop/src/main.single-spa.ts
+++ b/apps/shop/src/main.single-spa.ts
@@ -1,6 +1,6 @@
import { NavigationStart, Router } from '@angular/router';
import { bootstrapApplication, platformBrowser } from '@angular/platform-browser';
-import { singleSpaAngular, getSingleSpaExtraProviders } from 'single-spa-angular';
+import { singleSpaAngular, getSingleSpaExtraProviders } from '@single-spa-community/angular';
import { loadMontserrat } from './fonts';
import { appConfig } from './app/app.config';
diff --git a/cypress/integration/parcel.spec.js b/cypress/integration/parcel.spec.js
index 193fc7b7..2a3b0eed 100644
--- a/cypress/integration/parcel.spec.js
+++ b/cypress/integration/parcel.spec.js
@@ -5,7 +5,7 @@ Cypress.Screenshot.defaults({
});
describe('Angular parcel', () => {
- it('should navigate to /parcel and render the React widget and lazy component', () => {
+ it('should navigate to /parcel and render the React widget and lazy component AND update custom props', () => {
cy.visit('/parcel')
// GitHub Actions CI is not as fast as the local setup, there can be some network delays,
// timeout is used only for this purpose.
@@ -15,6 +15,10 @@ describe('Angular parcel', () => {
.invoke('attr', 'alt')
.should('eq', 'React logo')
.get('parcel-root parcel h1')
- .contains('Hola world');
+ .contains('Hola world')
+ .get('#update-custom-props')
+ .click()
+ .get('parcel-root parcel h1')
+ .contains('Bonjour world');
});
});
diff --git a/libs/single-spa-community-angular/internals/src/dom.ts b/libs/single-spa-community-angular/internals/src/dom.ts
index 2d7e2204..b05ad9b5 100644
--- a/libs/single-spa-community-angular/internals/src/dom.ts
+++ b/libs/single-spa-community-angular/internals/src/dom.ts
@@ -1,16 +1,12 @@
import { DomElementGetter, BaseSingleSpaAngularOptions } from './types';
-// This will be provided through Terser global definitions by Angular CLI. This will
-// help to tree-shake away the code unneeded for production bundles.
-declare const ngDevMode: boolean;
-
export function getContainerElementAndSetTemplate(
options: T,
props: any,
): HTMLElement {
const domElementGetter = chooseDomElementGetter(options, props);
- if ((typeof ngDevMode === 'undefined' || ngDevMode) && !domElementGetter) {
+ if (!domElementGetter) {
throw Error(
`Cannot mount angular application '${
props.name || props.appName
@@ -26,7 +22,7 @@ export function getContainerElementAndSetTemplate(null);
@@ -47,14 +42,19 @@ export class ParcelComponent {
const customProps = this.customProps();
untracked(() => {
this.scheduleTask(Action.Update, () => {
- this.parcel?.update?.(customProps);
+ this.parcel?.update?.({
+ ...customProps,
+ domElement: this.wrapper,
+ });
});
});
});
afterNextRender(() => {
this.scheduleTask(Action.Mount, () => {
- if ((typeof ngDevMode === 'undefined' || ngDevMode) && this.mountParcel === null) {
+ const mountParcel = this.mountParcel();
+
+ if (mountParcel === null) {
throw new Error(
'single-spa-angular: the [mountParcel] binding is required when using the component. You can either (1) import mountRootParcel from single-spa or (2) use the mountParcel prop provided to single-spa applications.',
);
@@ -69,8 +69,7 @@ export class ParcelComponent {
this.host.nativeElement.appendChild(this.wrapper);
}
- const mountParcel = this.mountParcel();
- this.parcel = mountParcel!(this.config()!, {
+ this.parcel = mountParcel(this.config()!, {
...this.customProps(),
domElement: this.wrapper,
});
@@ -88,7 +87,9 @@ export class ParcelComponent {
inject(DestroyRef).onDestroy(() => {
this.scheduleTask(Action.Unmount, () => {
if (this.parcel?.getStatus() === 'MOUNTED') {
- return this.parcel.unmount();
+ return this.parcel.unmount().then(() => {
+ this.parcel = null;
+ });
}
});
diff --git a/libs/single-spa-community-angular/src/extra-providers.ts b/libs/single-spa-community-angular/src/extra-providers.ts
index bfb7a348..f281bec9 100644
--- a/libs/single-spa-community-angular/src/extra-providers.ts
+++ b/libs/single-spa-community-angular/src/extra-providers.ts
@@ -5,8 +5,10 @@ import {
type LocationChangeEvent,
type LocationChangeListener,
} from '@angular/common';
-import { asyncScheduler, Subject, timer } from 'rxjs';
-import { switchMap, map } from 'rxjs/operators';
+import { Observable, Subject } from 'rxjs';
+import { switchMap } from 'rxjs/operators';
+
+import { runOutsideAngular } from './run-outside-angular';
declare const Zone: any;
@@ -39,13 +41,21 @@ export class SingleSpaPlatformLocation extends BrowserPlatformLocation {
// arrives. This is the key to avoiding infinite loops and race conditions during fast
// navigation: if the user navigates rapidly (e.g., hitting back/forward quickly),
// only the most recent popstate event will be processed. Earlier ones are discarded.
- switchMap(state =>
- // `timer(0, asyncScheduler)` defers execution to the next macrotask (via setTimeout).
- // This gives single-spa time to finish its own synchronous URL/state updates before
- // Angular's router reacts. Without this delay, Angular and single-spa could both
- // attempt to modify history state simultaneously, causing conflicts or infinite
- // navigation loops.
- timer(0, asyncScheduler).pipe(map(() => state)),
+ switchMap(
+ state =>
+ // setTimeout defers execution to the next macrotask.
+ // This gives single-spa time to finish its own synchronous URL/state updates before
+ // Angular's router reacts. Without this delay, Angular and single-spa could both
+ // attempt to modify history state simultaneously, causing conflicts or infinite
+ // navigation loops.
+ new Observable<[LocationChangeListener, LocationChangeEvent]>(subscriber =>
+ runOutsideAngular(() => {
+ const timeoutId = setTimeout(() => {
+ subscriber.next(state);
+ });
+ return () => clearTimeout(timeoutId);
+ }),
+ ),
),
)
.subscribe(([fn, event]) => {
diff --git a/libs/single-spa-community-angular/src/prod-mode.ts b/libs/single-spa-community-angular/src/prod-mode.ts
deleted file mode 100644
index d1bcae0e..00000000
--- a/libs/single-spa-community-angular/src/prod-mode.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import * as core from '@angular/core';
-
-export function enableProdMode(): void {
- try {
- // The `enableProdMode` will throw an error if it's called multiple times,
- // but it may be called multiple times when dependencies are shared.
- core.enableProdMode();
- } catch {
- // Nothing to do here.
- }
-}
diff --git a/libs/single-spa-community-angular/src/public_api.ts b/libs/single-spa-community-angular/src/public_api.ts
index 6c12af42..cc41af60 100644
--- a/libs/single-spa-community-angular/src/public_api.ts
+++ b/libs/single-spa-community-angular/src/public_api.ts
@@ -1,3 +1,2 @@
-export { enableProdMode } from './prod-mode';
export { singleSpaAngular } from './single-spa-angular';
export { getSingleSpaExtraProviders } from './extra-providers';
diff --git a/libs/single-spa-community-angular/src/run-outside-angular.ts b/libs/single-spa-community-angular/src/run-outside-angular.ts
new file mode 100644
index 00000000..def90c55
--- /dev/null
+++ b/libs/single-spa-community-angular/src/run-outside-angular.ts
@@ -0,0 +1,7 @@
+declare const Zone: any;
+
+export function runOutsideAngular(fn: () => T): T {
+ return typeof Zone !== 'undefined' && typeof Zone?.root?.run === 'function'
+ ? Zone.root.run(fn)
+ : fn();
+}
diff --git a/libs/single-spa-community-angular/src/single-spa-angular.ts b/libs/single-spa-community-angular/src/single-spa-angular.ts
index 94a0c7f8..5f4a345d 100644
--- a/libs/single-spa-community-angular/src/single-spa-angular.ts
+++ b/libs/single-spa-community-angular/src/single-spa-angular.ts
@@ -14,17 +14,11 @@ const defaultOptions = {
Router: undefined,
domElementGetter: undefined, // only optional if you provide a domElementGetter as a custom prop
updateFunction: () => Promise.resolve(),
- bootstrappedNgModuleRefOrAppRef: null,
+ bootstrappedRef: null,
};
-// This will be provided through Terser global definitions by Angular CLI. This will
-// help to tree-shake away the code unneeded for production bundles.
-declare const ngDevMode: boolean;
-
-const NG_DEV_MODE = typeof ngDevMode === 'undefined' || ngDevMode;
-
export function singleSpaAngular(userOptions: SingleSpaAngularOptions): LifeCycles {
- if (NG_DEV_MODE && typeof userOptions !== 'object') {
+ if (typeof userOptions !== 'object') {
throw Error('single-spa-angular requires a configuration object');
}
@@ -33,19 +27,19 @@ export function singleSpaAngular(userOptions: SingleSpaAngularOptions): Li
...userOptions,
};
- if (NG_DEV_MODE && typeof options.bootstrapFunction !== 'function') {
+ if (typeof options.bootstrapFunction !== 'function') {
throw Error('single-spa-angular must be passed an options.bootstrapFunction');
}
- if (NG_DEV_MODE && typeof options.template !== 'string') {
+ if (typeof options.template !== 'string') {
throw Error('single-spa-angular must be passed options.template string');
}
- if (NG_DEV_MODE && !options.NgZone) {
+ if (!options.NgZone) {
throw Error(`single-spa-angular must be passed the NgZone option`);
}
- if (NG_DEV_MODE && options.Router && !options.NavigationStart) {
+ if (options.Router && !options.NavigationStart) {
// We call `console.warn` except of throwing `new Error()` since this will not
// be a breaking change.
console.warn(`single-spa-angular must be passed the NavigationStart option`);
@@ -60,27 +54,31 @@ export function singleSpaAngular(userOptions: SingleSpaAngularOptions): Li
}
async function bootstrap(options: BootstrappedSingleSpaAngularOptions): Promise {
- // Angular provides an opportunity to develop `zone-less` application, where developers
- // have to trigger change detection manually.
- // See https://angular.io/guide/zone#noopzone
if (options.NgZone === 'noop') {
return;
}
- // Note that we have to make it a noop function because it's a static property and not
- // an instance property. We're unable to configure it for multiple apps when dependencies
- // are shared and reference the same `NgZone` class. We can't determine where this function
- // is being executed or under which application, making it difficult to assert whether this
- // app is running under its zone.
+ // `NgZone.assertInAngularZone` and `NgZone.assertNotInAngularZone` are static methods,
+ // meaning they are shared across all instances of `NgZone`. When multiple Angular apps
+ // share dependencies (i.e. the same `NgZone` class reference), these assertions become
+ // unreliable because they cannot distinguish which application's zone is currently active.
+ // For example, app A's zone could be active while app B's assertion fires, causing false
+ // negatives. To avoid misleading errors in a microfrontend environment where multiple
+ // Angular zones coexist on the same page, we replace both methods with no-ops.
options.NgZone.assertInAngularZone = () => {};
options.NgZone.assertNotInAngularZone = () => {};
+ // single-spa intercepts browser navigation events (pushState, replaceState, popstate)
+ // and orchestrates routing across all mounted microfrontends. However, Zone.js is unaware
+ // of these navigation changes because they happen outside Angular's zone — single-spa
+ // dispatches its own routing events rather than going through Angular's router lifecycle.
+ // As a result, Angular's change detection is never triggered after a single-spa navigation.
+ // To fix this, we register a routing event listener that explicitly re-enters the app's
+ // Angular zone via `NgZone.run()`, which signals to Angular that something has changed
+ // and change detection should run.
+ // See https://github.com/single-spa/single-spa-angular/issues/86
options.routingEventListener = () => {
- options.bootstrappedNgZone!.run(() => {
- // See https://github.com/single-spa/single-spa-angular/issues/86
- // Zone is unaware of the single-spa navigation change and so Angular change detection doesn't work
- // unless we tell Zone that something happened
- });
+ options.bootstrappedNgZone!.run(() => {});
};
}
@@ -92,56 +90,62 @@ async function mount(
const bootstrapPromise = options.bootstrapFunction(props);
- if (NG_DEV_MODE && !(bootstrapPromise instanceof Promise)) {
+ if (!(bootstrapPromise instanceof Promise)) {
throw Error(
`single-spa-angular: the options.bootstrapFunction must return a promise, but instead returned a '${typeof bootstrapPromise}' that is not a Promise`,
);
}
- const ngModuleRefOrAppRef: NgModuleRef | ApplicationRef = await bootstrapPromise;
+ const bootstrappedRef = await bootstrapPromise;
- if (NG_DEV_MODE) {
- if (!ngModuleRefOrAppRef || typeof ngModuleRefOrAppRef.destroy !== 'function') {
- throw Error(
- `single-spa-angular: the options.bootstrapFunction returned a promise that did not resolve with a valid Angular module or ApplicationRef. Did you call platformBrowserDynamic().bootstrapModule() correctly?`,
- );
- }
+ if (typeof bootstrappedRef?.destroy !== 'function') {
+ throw Error(
+ `single-spa-angular: the options.bootstrapFunction returned a promise that did not resolve with a valid Angular module or ApplicationRef. Did you call platformBrowserDynamic().bootstrapModule() correctly?`,
+ );
}
- const singleSpaPlatformLocation = ngModuleRefOrAppRef.injector.get(
- SingleSpaPlatformLocation,
- null,
- );
-
- const ngZoneEnabled = options.NgZone !== 'noop';
-
- // The user has to provide `BrowserPlatformLocation` only if his application uses routing.
- // So if he provided `Router` but didn't provide `BrowserPlatformLocation` then we have to inform him.
- // Also `getSingleSpaExtraProviders()` function should be called only if the user doesn't use
- // `zone-less` change detection, if `NgZone` is `noop` then we can skip it.
- if (NG_DEV_MODE && ngZoneEnabled && options.Router && singleSpaPlatformLocation === null) {
+ const singleSpaPlatformLocation = bootstrappedRef.injector.get(SingleSpaPlatformLocation, null);
+
+ // `getSingleSpaExtraProviders()` must be passed to `platformBrowser()` when the application
+ // uses Angular's router. It registers `SingleSpaPlatformLocation` which overrides
+ // `BrowserPlatformLocation` to handle popstate events correctly in a microfrontend environment.
+ // Without it, Angular's router and single-spa will conflict when handling browser navigation,
+ // leading to infinite loops or incorrect routing behavior.
+ //
+ // However, if the app is running in zoneless mode (`NgZone: 'noop'`), change detection is
+ // managed manually and the platform location override is not needed, so we skip this check.
+ //
+ // If the user provided a `Router` but `SingleSpaPlatformLocation` is not present in the
+ // platform injector, it means `getSingleSpaExtraProviders()` was not passed to `platformBrowser()`
+ // and we throw a descriptive error to guide them toward the fix.
+ if (options.Router && singleSpaPlatformLocation === null) {
throw new Error(`
- single-spa-angular: could not retrieve extra providers from the platform injector. Did you call platformBrowserDynamic(getSingleSpaExtraProviders()).bootstrapModule()?
- `);
+ single-spa-angular: could not retrieve extra providers from the platform injector. Did you add getSingleSpaExtraProviders()?
+ `);
}
const bootstrappedOptions = options as BootstrappedSingleSpaAngularOptions;
- if (ngZoneEnabled) {
- const ngZone: NgZone = ngModuleRefOrAppRef.injector.get(options.NgZone);
+ if (options.NgZone !== 'noop') {
+ const ngZone: NgZone = bootstrappedRef.injector.get(options.NgZone);
- // `NgZone` can be enabled but routing may not be used thus `getSingleSpaExtraProviders()`
- // function was not called.
+ // The app may use `NgZone` but not Angular's router (e.g. a microfrontend that manages
+ // its own navigation or has no routing at all). In that case, `getSingleSpaExtraProviders()`
+ // would not have been called and `SingleSpaPlatformLocation` would not be registered in
+ // the platform injector. We only wire up the popstate skip logic when we can confirm
+ // that `SingleSpaPlatformLocation` is present, since `skipLocationChangeOnNonImperativeRoutingTriggers`
+ // relies on it to distinguish synthetic single-spa navigation events from genuine
+ // browser back/forward navigation.
if (singleSpaPlatformLocation !== null) {
- skipLocationChangeOnNonImperativeRoutingTriggers(ngModuleRefOrAppRef, options);
+ skipLocationChangeOnNonImperativeRoutingTriggers(bootstrappedRef, options);
}
bootstrappedOptions.bootstrappedNgZone = ngZone;
window.addEventListener('single-spa:routing-event', bootstrappedOptions.routingEventListener!);
}
- bootstrappedOptions.bootstrappedNgModuleRefOrAppRef = ngModuleRefOrAppRef;
- return ngModuleRefOrAppRef;
+ bootstrappedOptions.bootstrappedRef = bootstrappedRef;
+ return bootstrappedRef;
}
function unmount(options: BootstrappedSingleSpaAngularOptions): Promise {
@@ -150,33 +154,44 @@ function unmount(options: BootstrappedSingleSpaAngularOptions): Promise {
window.removeEventListener('single-spa:routing-event', options.routingEventListener);
}
- options.bootstrappedNgModuleRefOrAppRef!.destroy();
- options.bootstrappedNgModuleRefOrAppRef = null;
+ options.bootstrappedRef!.destroy();
+ options.bootstrappedRef = null;
});
}
function skipLocationChangeOnNonImperativeRoutingTriggers(
- ngModuleRefOrAppRef: NgModuleRef | ApplicationRef,
+ bootstrappedRef: NgModuleRef | ApplicationRef,
options: SingleSpaAngularOptions,
): void {
const { NavigationStart, Router } = options;
if (!NavigationStart || !Router) {
- // As discussed we don't do anything right now if the developer doesn't provide
- // `options.NavigationStart` since this might be a breaking change.
+ // `NavigationStart` and `Router` must both be provided in `singleSpaAngular()` options
+ // for this optimization to work. We intentionally do nothing if they are absent rather
+ // than throwing, because adding this as a hard requirement would be a breaking change
+ // for existing users who haven't provided these options.
return;
}
- const router = ngModuleRefOrAppRef.injector.get(Router);
+ const router = bootstrappedRef.injector.get(Router);
const subscription = router.events.subscribe((event: any) => {
if (event instanceof NavigationStart) {
const currentNavigation = router.getCurrentNavigation();
- // This listener will be set up for each Angular application
- // that has routing capabilities.
- // We set `skipLocationChange` for each non-imperative navigation,
- // Angular router checks under the hood if it has to change
- // the browser URL or not.
- // If `skipLocationChange` is truthy then Angular router will not call
- // `setBrowserUrl()` which calls `history.replaceState()` and dispatches `popstate` event.
+
+ // In a single-spa microfrontend environment, multiple apps share the same browser URL.
+ // When single-spa triggers a routing change (e.g. via popstate or its own navigation
+ // events), Angular's router responds and would normally call `setBrowserUrl()` internally,
+ // which calls `history.replaceState()` and dispatches a new `popstate` event. This creates
+ // a feedback loop: single-spa triggers Angular, Angular updates the URL, which triggers
+ // single-spa again, and so on.
+ //
+ // To break this cycle, we intercept every non-imperative navigation (i.e. navigations
+ // triggered by popstate or single-spa routing events, rather than by explicit router.navigate()
+ // calls in application code) and set `skipLocationChange: true`. This tells Angular's router
+ // to perform the navigation and update its internal state without calling `history.replaceState()`,
+ // preventing the redundant popstate event that would otherwise cause the infinite loop.
+ //
+ // `replaceUrl: false` is also set to ensure Angular does not attempt to replace the current
+ // history entry, which would have the same undesirable side effect.
if (currentNavigation.trigger !== 'imperative') {
currentNavigation.extras.skipLocationChange = true;
currentNavigation.extras.replaceUrl = false;
@@ -184,5 +199,5 @@ function skipLocationChangeOnNonImperativeRoutingTriggers(
}
});
- ngModuleRefOrAppRef.onDestroy(() => subscription.unsubscribe());
+ bootstrappedRef.onDestroy(() => subscription.unsubscribe());
}
diff --git a/libs/single-spa-community-angular/src/types.ts b/libs/single-spa-community-angular/src/types.ts
index e8aa3817..56de7455 100644
--- a/libs/single-spa-community-angular/src/types.ts
+++ b/libs/single-spa-community-angular/src/types.ts
@@ -16,7 +16,7 @@ export interface SingleSpaAngularOptions<
}
export interface BootstrappedSingleSpaAngularOptions extends SingleSpaAngularOptions {
- bootstrappedNgModuleRefOrAppRef: NgModuleRef | ApplicationRef | null;
+ bootstrappedRef: NgModuleRef | ApplicationRef | null;
// All below properties can be optional in case of
// `SingleSpaAngularOpts.NgZone` is a `noop` string and not an `NgZone` class.
bootstrappedNgZone?: NgZone;
diff --git a/libs/single-spa-community-angular/webpack/externals.ts b/libs/single-spa-community-angular/webpack/externals.ts
index bc2637ee..52e01844 100644
--- a/libs/single-spa-community-angular/webpack/externals.ts
+++ b/libs/single-spa-community-angular/webpack/externals.ts
@@ -17,12 +17,15 @@ export const externals = [
'@angular/core/primitives/signals',
'@angular/core/primitives/event-dispatch',
+ '@angular/core/primitives/di',
'@angular/core',
'@angular/core/rxjs-interop',
'@angular/elements',
'@angular/forms',
+ '@angular/forms/signals',
+ '@angular/forms/signals/compat',
'@angular/localize',
'@angular/localize/init',
@@ -43,8 +46,8 @@ export const externals = [
'@angular/upgrade/static',
'single-spa',
- 'single-spa-angular/internals',
- 'single-spa-angular',
- 'single-spa-angular/elements',
- 'single-spa-angular/parcel',
+ '@single-spa-community/angular/internals',
+ '@single-spa-community/angular',
+ '@single-spa-community/angular/elements',
+ '@single-spa-community/angular/parcel',
];
diff --git a/schematics/ng-add/_files/extra-webpack.config.js.template b/schematics/ng-add/_files/extra-webpack.config.js.template
index 73a1ee2a..63e987a6 100644
--- a/schematics/ng-add/_files/extra-webpack.config.js.template
+++ b/schematics/ng-add/_files/extra-webpack.config.js.template
@@ -1,4 +1,4 @@
-const singleSpaAngularWebpack = require('single-spa-angular/lib/webpack').default;
+const singleSpaAngularWebpack = require('@single-spa-community/angular/lib/webpack').default;
module.exports = (config, options) => {
const singleSpaWebpackConfig = singleSpaAngularWebpack(config, options);
diff --git a/schematics/ng-add/_files/src/main.single-spa.ts.template b/schematics/ng-add/_files/src/main.single-spa.ts.template
index c949ce10..88c04313 100644
--- a/schematics/ng-add/_files/src/main.single-spa.ts.template
+++ b/schematics/ng-add/_files/src/main.single-spa.ts.template
@@ -1,25 +1,30 @@
-<% if (routing) { %>import { enableProdMode } from '@angular/core';<% } %>
-<% if (!routing) { %>import { enableProdMode } from '@angular/core';<% } %>
-import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';<% if (routing) { %>
-import { Router, NavigationStart } from '@angular/router';<% } %>
+<% if (routing) { %>
+import { bootstrapApplication, platformBrowser } from '@angular/platform-browser';
+import { NavigationStart, Router } from '@angular/router';
+import { singleSpaAngular, getSingleSpaExtraProviders } from '@single-spa-community/angular'
+<% } else { %>
+import { bootstrapApplication, platformBrowser } from '@angular/platform-browser';
+import { singleSpaAngular } from '@single-spa-community/angular'
+<% } %>
-<% if (routing) { %>import { singleSpaAngular, getSingleSpaExtraProviders } from 'single-spa-angular';
-<% } else { %>import { singleSpaAngular } from 'single-spa-angular';<% } %>
-
-import { AppModule } from './app/app.module';
-import { environment } from './environments/environment';
-import { singleSpaPropsSubject } from './single-spa/single-spa-props';
+import { appConfig } from './app/app.config';
+import { AppComponent } from './app/app.component';
+import { singleSpaProps } from './single-spa/single-spa-props';
const lifecycles = singleSpaAngular({
bootstrapFunction: singleSpaProps => {
- singleSpaPropsSubject.next(singleSpaProps);<% if (routing) { %>
- return platformBrowserDynamic(getSingleSpaExtraProviders()).bootstrapModule(AppModule);<% } else { %>
- return platformBrowserDynamic().bootstrapModule(AppModule);<% } %>
+ singleSpaProps.set(singleSpaProps);
+ <% if (routing) { %>
+ const platformRef = platformBrowser(getSingleSpaExtraProviders());
+ <% } else { %>
+ const platformRef = platformBrowser();
+ <% } %>
+ return bootstrapApplication(AppComponent, appConfig, { platformRef });
},
+ NgZone: 'noop',
template: '<<%= prefix %>-root />',<% if (routing) { %>
Router,
NavigationStart,<% } %>
- NgZone: 'noop',
});
export const bootstrap = lifecycles.bootstrap;
diff --git a/schematics/ng-add/_files/src/single-spa/single-spa-props.ts.template b/schematics/ng-add/_files/src/single-spa/single-spa-props.ts.template
index 38ffe644..d4c66764 100644
--- a/schematics/ng-add/_files/src/single-spa/single-spa-props.ts.template
+++ b/schematics/ng-add/_files/src/single-spa/single-spa-props.ts.template
@@ -1,7 +1,7 @@
-import { ReplaySubject } from 'rxjs';
-import { AppProps } from 'single-spa';
+import { signal } from '@angular/core';
+import type { AppProps } from 'single-spa';
-export const singleSpaPropsSubject = new ReplaySubject(1);
+export const singleSpaProps = signal(null);
// Add any custom single-spa props you have to this type def
// https://single-spa.js.org/docs/building-applications.html#custom-props