diff --git a/apps/chat/src/main.single-spa.ts b/apps/chat/src/main.single-spa.ts
index fd7e6b6f..2138a475 100644
--- a/apps/chat/src/main.single-spa.ts
+++ b/apps/chat/src/main.single-spa.ts
@@ -1,7 +1,10 @@
-import { NgZone } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { NavigationStart, Router } from '@angular/router';
-import { singleSpaAngular, getSingleSpaExtraProviders, enableProdMode } from 'single-spa-angular';
+import {
+ singleSpaAngular,
+ getSingleSpaExtraProviders,
+ enableProdMode,
+} from '@single-spa-community/angular';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
@@ -16,6 +19,7 @@ const lifecycles = singleSpaAngular({
singleSpaPropsSubject.next(singleSpaProps);
const ngModuleRef = await platformBrowserDynamic(getSingleSpaExtraProviders()).bootstrapModule(
AppModule,
+ { ngZone: 'noop' },
);
ngModuleRef.onDestroy(() => {
// This is used only for testing purposes.
@@ -24,7 +28,7 @@ const lifecycles = singleSpaAngular({
return ngModuleRef;
},
template: '',
- NgZone,
+ NgZone: 'noop',
Router,
NavigationStart,
});
diff --git a/apps/elements/src/main.single-spa.ts b/apps/elements/src/main.single-spa.ts
index 19b262a7..69b895af 100644
--- a/apps/elements/src/main.single-spa.ts
+++ b/apps/elements/src/main.single-spa.ts
@@ -1,6 +1,6 @@
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
-import { singleSpaAngularElements } from 'single-spa-angular/elements';
-import { enableProdMode, getSingleSpaExtraProviders } from 'single-spa-angular';
+import { singleSpaAngularElements } from '@single-spa-community/angular/elements';
+import { enableProdMode, getSingleSpaExtraProviders } from '@single-spa-community/angular';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
@@ -19,9 +19,7 @@ const lifecycles = singleSpaAngularElements({
const ngModuleRef = await platformBrowserDynamic(getSingleSpaExtraProviders()).bootstrapModule(
AppModule,
- {
- ngZone: 'noop',
- },
+ { ngZone: 'noop' },
);
ngModuleRef.onDestroy(() => unmountableStyles.unuse());
diff --git a/apps/navbar/src/main.single-spa.ts b/apps/navbar/src/main.single-spa.ts
index fd217ee0..bb1cd39c 100644
--- a/apps/navbar/src/main.single-spa.ts
+++ b/apps/navbar/src/main.single-spa.ts
@@ -1,7 +1,10 @@
-import { NgZone } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { NavigationStart, Router } from '@angular/router';
-import { singleSpaAngular, getSingleSpaExtraProviders, enableProdMode } from 'single-spa-angular';
+import {
+ singleSpaAngular,
+ getSingleSpaExtraProviders,
+ enableProdMode,
+} from '@single-spa-community/angular';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
@@ -12,9 +15,11 @@ if (environment.production) {
const lifecycles = singleSpaAngular({
bootstrapFunction: () =>
- platformBrowserDynamic(getSingleSpaExtraProviders()).bootstrapModule(AppModule),
+ platformBrowserDynamic(getSingleSpaExtraProviders()).bootstrapModule(AppModule, {
+ ngZone: 'noop',
+ }),
template: '',
- NgZone,
+ NgZone: 'noop',
Router,
NavigationStart,
});
diff --git a/apps/noop-zone/src/main.single-spa.ts b/apps/noop-zone/src/main.single-spa.ts
index a50148d2..971bafa2 100644
--- a/apps/noop-zone/src/main.single-spa.ts
+++ b/apps/noop-zone/src/main.single-spa.ts
@@ -1,7 +1,11 @@
import { ApplicationRef } from '@angular/core';
import { NavigationStart, Router } from '@angular/router';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
-import { singleSpaAngular, enableProdMode, getSingleSpaExtraProviders } from 'single-spa-angular';
+import {
+ singleSpaAngular,
+ enableProdMode,
+ getSingleSpaExtraProviders,
+} from '@single-spa-community/angular';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
diff --git a/apps/parcel/src/app/app.module.ts b/apps/parcel/src/app/app.module.ts
index b898cc83..0132f710 100644
--- a/apps/parcel/src/app/app.module.ts
+++ b/apps/parcel/src/app/app.module.ts
@@ -1,6 +1,6 @@
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
-import { ParcelModule } from 'single-spa-angular/parcel';
+import { ParcelModule } from '@single-spa-community/angular/parcel';
import { AppComponent } from './app.component';
diff --git a/apps/parcel/src/main.single-spa.ts b/apps/parcel/src/main.single-spa.ts
index bd45ed8c..b06587e8 100644
--- a/apps/parcel/src/main.single-spa.ts
+++ b/apps/parcel/src/main.single-spa.ts
@@ -1,6 +1,5 @@
-import { NgZone } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
-import { singleSpaAngular, enableProdMode } from 'single-spa-angular';
+import { singleSpaAngular, enableProdMode } from '@single-spa-community/angular';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
@@ -10,9 +9,9 @@ if (environment.production) {
}
const lifecycles = singleSpaAngular({
- bootstrapFunction: () => platformBrowserDynamic().bootstrapModule(AppModule),
+ bootstrapFunction: () => platformBrowserDynamic().bootstrapModule(AppModule, { ngZone: 'noop' }),
template: '',
- NgZone,
+ NgZone: 'noop',
});
export const bootstrap = lifecycles.bootstrap;
diff --git a/apps/root-config/src/index.ejs b/apps/root-config/src/index.ejs
index 7dca109b..0abe99b7 100644
--- a/apps/root-config/src/index.ejs
+++ b/apps/root-config/src/index.ejs
@@ -54,10 +54,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/root-config/src/main.js b/apps/root-config/src/main.js
index 913c6981..15ac2d0f 100644
--- a/apps/root-config/src/main.js
+++ b/apps/root-config/src/main.js
@@ -1,47 +1,45 @@
-import('zone.js').then(() => {
- System.import('single-spa').then(({ registerApplication, start }) => {
- registerApplication({
- name: 'navbar',
- app: () => System.import('navbar'),
- activeWhen: () => true,
- });
-
- registerApplication({
- name: 'shop',
- app: () => System.import('shop'),
- activeWhen: location => location.pathname.startsWith('/shop'),
- });
+System.import('single-spa').then(({ registerApplication, start }) => {
+ registerApplication({
+ name: 'navbar',
+ app: () => System.import('navbar'),
+ activeWhen: () => true,
+ });
- registerApplication({
- name: 'chat',
- app: () => System.import('chat'),
- activeWhen: location => location.pathname.startsWith('/chat'),
- });
+ registerApplication({
+ name: 'shop',
+ app: () => System.import('shop'),
+ activeWhen: location => location.pathname.startsWith('/shop'),
+ });
- registerApplication({
- name: 'noop-zone',
- app: () => System.import('noop-zone'),
- activeWhen: location => location.pathname.startsWith('/noop-zone'),
- });
+ registerApplication({
+ name: 'chat',
+ app: () => System.import('chat'),
+ activeWhen: location => location.pathname.startsWith('/chat'),
+ });
- registerApplication({
- name: 'elements',
- app: () => System.import('elements'),
- activeWhen: location => location.pathname.startsWith('/elements'),
- });
+ registerApplication({
+ name: 'noop-zone',
+ app: () => System.import('noop-zone'),
+ activeWhen: location => location.pathname.startsWith('/noop-zone'),
+ });
- registerApplication({
- name: 'parcel',
- app: () => System.import('parcel'),
- activeWhen: location => location.pathname.startsWith('/parcel'),
- });
+ registerApplication({
+ name: 'elements',
+ app: () => System.import('elements'),
+ activeWhen: location => location.pathname.startsWith('/elements'),
+ });
- registerApplication({
- name: 'standalone',
- app: () => System.import('standalone'),
- activeWhen: location => location.pathname.startsWith('/standalone'),
- });
+ registerApplication({
+ name: 'parcel',
+ app: () => System.import('parcel'),
+ activeWhen: location => location.pathname.startsWith('/parcel'),
+ });
- start();
+ registerApplication({
+ name: 'standalone',
+ app: () => System.import('standalone'),
+ activeWhen: location => location.pathname.startsWith('/standalone'),
});
+
+ start();
});
diff --git a/apps/shop/src/main.single-spa.ts b/apps/shop/src/main.single-spa.ts
index 432e60a1..35bcb744 100644
--- a/apps/shop/src/main.single-spa.ts
+++ b/apps/shop/src/main.single-spa.ts
@@ -1,7 +1,10 @@
-import { NgZone } from '@angular/core';
import { NavigationStart, Router } from '@angular/router';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
-import { singleSpaAngular, getSingleSpaExtraProviders, enableProdMode } from 'single-spa-angular';
+import {
+ singleSpaAngular,
+ getSingleSpaExtraProviders,
+ enableProdMode,
+} from '@single-spa-community/angular';
import { loadMontserrat } from './fonts';
import { AppModule } from './app/app.module';
@@ -14,12 +17,14 @@ if (environment.production) {
const lifecycles = singleSpaAngular({
bootstrapFunction: () =>
loadMontserrat().then(() =>
- platformBrowserDynamic(getSingleSpaExtraProviders()).bootstrapModule(AppModule),
+ platformBrowserDynamic(getSingleSpaExtraProviders()).bootstrapModule(AppModule, {
+ ngZone: 'noop',
+ }),
),
template: '',
Router,
NavigationStart,
- NgZone,
+ NgZone: 'noop',
});
export const bootstrap = lifecycles.bootstrap;
diff --git a/apps/standalone/src/main.single-spa.ts b/apps/standalone/src/main.single-spa.ts
index aa4ee016..791948ba 100644
--- a/apps/standalone/src/main.single-spa.ts
+++ b/apps/standalone/src/main.single-spa.ts
@@ -1,7 +1,6 @@
-import { NgZone } from '@angular/core';
import { NavigationStart, Router } from '@angular/router';
import { bootstrapApplication } from '@angular/platform-browser';
-import { singleSpaAngular, enableProdMode } from 'single-spa-angular';
+import { singleSpaAngular, enableProdMode } from '@single-spa-community/angular';
import { appConfig } from './app/app.config';
import { environment } from './environments/environment';
@@ -16,7 +15,7 @@ const lifecycles = singleSpaAngular({
template: '',
Router,
NavigationStart,
- NgZone,
+ NgZone: 'noop',
});
export const bootstrap = lifecycles.bootstrap;
diff --git a/libs/single-spa-community-angular/src/extra-providers.ts b/libs/single-spa-community-angular/src/extra-providers.ts
index 7f04c98e..6da0d810 100644
--- a/libs/single-spa-community-angular/src/extra-providers.ts
+++ b/libs/single-spa-community-angular/src/extra-providers.ts
@@ -1,57 +1,116 @@
-import { Injectable, StaticProvider, Inject } from '@angular/core';
+import { inject, PlatformRef, type StaticProvider } from '@angular/core';
import {
BrowserPlatformLocation,
PlatformLocation,
type LocationChangeEvent,
type LocationChangeListener,
- DOCUMENT,
} from '@angular/common';
+import { Observable, Subject } from 'rxjs';
+import { switchMap } from 'rxjs/operators';
declare const Zone: any;
-@Injectable()
+function runOutsideAngular(fn: () => T): T {
+ return typeof Zone !== 'undefined' && typeof Zone?.root?.run === 'function'
+ ? Zone.root.run(fn)
+ : fn();
+}
+
export class SingleSpaPlatformLocation extends BrowserPlatformLocation {
- // This is a simple marker that helps us to ignore PopStateEvents
- // that was not dispatched by the browser.
+ // When `pushState` or `replaceState` is called, single-spa will dispatch a synthetic
+ // `popstate` event to notify other apps of the URL change. We use this flag to
+ // distinguish those synthetic events from genuine browser back/forward navigation,
+ // so we can skip processing them and avoid triggering redundant Angular router updates.
private skipNextPopState = false;
private readonly source = 'Window.addEventListener:popstate';
+ // A Subject that buffers [listener, event] pairs for processing.
+ // Using a Subject here allows us to apply RxJS operators (switchMap + timer)
+ // to debounce and defer popstate handling, which is critical for fast navigation.
+ private readonly onPopState$ = new Subject<[LocationChangeListener, LocationChangeEvent]>();
+
+ constructor() {
+ super();
+
+ const platform = inject(PlatformRef);
+
+ // Clean up the Subject when the Angular platform is destroyed (e.g., app unmount in single-spa)
+ // to prevent memory leaks and dangling subscriptions.
+ platform.onDestroy(() => this.onPopState$.complete());
+
+ this.onPopState$
+ .pipe(
+ // `switchMap` cancels any pending timer from a previous popstate event when a new one
+ // 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 =>
+ // 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]) => {
+ // single-spa adds a `singleSpa` property to popstate events it dispatches itself
+ // (introduced in single-spa v5.4). This lets us distinguish synthetic events
+ // (triggered programmatically by single-spa) from genuine browser navigation events
+ // (triggered by the user pressing back/forward).
+ const popStateEventWasDispatchedBySingleSpa = !!(event as unknown as { singleSpa: boolean })
+ .singleSpa;
+
+ if (this.skipNextPopState && popStateEventWasDispatchedBySingleSpa) {
+ // This popstate event was dispatched by single-spa in response to our own
+ // `pushState`/`replaceState` call. Skip it to prevent Angular from processing
+ // a navigation it already initiated, and reset the flag for the next event.
+ this.skipNextPopState = false;
+ } else {
+ // This is either a genuine browser navigation event, or a single-spa event
+ // that we did not initiate ourselves. Let Angular's router handle it normally.
+ fn(event);
+ }
+ });
+ }
+
pushState(state: any, title: string, url: string): void {
+ // Set the flag before calling the native pushState. single-spa listens to pushState
+ // and will synchronously dispatch a synthetic popstate event in response. By setting
+ // this flag first, we ensure that synthetic event gets ignored when it arrives.
this.skipNextPopState = true;
super.pushState(state, title, url);
}
replaceState(state: any, title: string, url: string): void {
+ // Same reasoning as pushState above — set the flag before the native call
+ // so the resulting synthetic popstate event from single-spa is skipped.
this.skipNextPopState = true;
super.replaceState(state, title, url);
}
onPopState(fn: LocationChangeListener): VoidFunction {
- // `Zone.current` will reference the zone that serves as an execution context
- // to some specific application, especially when `onPopState` is called.
- const zone = Zone.current;
-
- // Wrap any event listener into zone that is specific to some application.
- // The main issue is `back/forward` buttons of browsers, because they invoke
- // `history.back|forward` which dispatch `popstate` event. Since `single-spa`
- // overrides `history.replaceState` Angular's zone cannot intercept this event.
- // Only the root zone is able to intercept all events.
- // See https://github.com/single-spa/single-spa-angular/issues/94 for more details
- fn = zone.wrap(fn, this.source);
+ // Wrap the listener in the current Zone.js zone so that Angular's change detection
+ // is triggered correctly when the listener runs. This is necessary because popstate
+ // events from browser back/forward navigation are dispatched in the root zone, outside
+ // of Angular's zone. single-spa overrides `history.replaceState`, which prevents
+ // Angular's zone from intercepting these events automatically.
+ // See https://github.com/single-spa/single-spa-angular/issues/94 for full context.
+ fn = typeof Zone !== 'undefined' && Zone?.current ? Zone.current.wrap(fn, this.source) : fn;
const onPopStateListener = (event: LocationChangeEvent) => {
- // The `LocationChangeEvent` doesn't have the `singleSpa` property, since it's added
- // by `single-spa` starting from `5.4` version. We need this check because we want
- // to skip "unnatural" PopStateEvents, the one caused by `single-spa`.
- const popStateEventWasDispatchedBySingleSpa = !!(event as unknown as { singleSpa: boolean })
- .singleSpa;
-
- if (this.skipNextPopState && popStateEventWasDispatchedBySingleSpa) {
- this.skipNextPopState = false;
- } else {
- fn(event);
- }
+ // Instead of calling `fn` directly, push the event into the Subject so it can be
+ // debounced and deferred via the switchMap + timer pipeline in the constructor.
+ this.onPopState$.next([fn, event]);
};
return super.onPopState(onPopStateListener);
@@ -59,18 +118,39 @@ export class SingleSpaPlatformLocation extends BrowserPlatformLocation {
}
/**
- * The `PlatformLocation` class is an "injectee" of the `PathLocationStrategy`,
- * which creates `Subject` internally for listening on `popstate` events. We want
- * to provide this class in the most top injector that's used during bootstrapping.
+ * The `PlatformLocation` class is injected into `PathLocationStrategy`,
+ * which creates a `Subject` internally for listening to `popstate` events. We provide
+ * this custom class in the root injector used during application bootstrapping.
+ *
+ * THIS IS REQUIRED FOR ALL APPLICATIONS (BOTH ZONE AND ZONELESS). Pass the result of
+ * this function to `platformBrowser()` when bootstrapping your application:
+ *
+ * @example
+ * const lifecycles = singleSpaAngular({
+ * bootstrapFunction: async () => {
+ * const platformRef = platformBrowser(getSingleSpaExtraProviders());
+ * return bootstrapApplication(AppComponent, appConfig, { platformRef });
+ * },
+ * template: '',
+ * NgZone: 'noop',
+ * Router,
+ * NavigationStart,
+ * });
*/
export function getSingleSpaExtraProviders(): StaticProvider[] {
return [
{
provide: SingleSpaPlatformLocation,
- deps: [[new Inject(DOCUMENT)]],
+ // Using `useClass` would necessitate decorating `SingleSpaPlatformLocation`
+ // with `@Injectable`. Using `useFactory` avoids that requirement while still
+ // allowing Angular's DI to manage the instance.
+ useFactory: () => new SingleSpaPlatformLocation(),
},
{
provide: PlatformLocation,
+ // Alias `PlatformLocation` to our custom implementation so that Angular's
+ // `PathLocationStrategy` (and anything else that injects `PlatformLocation`)
+ // uses `SingleSpaPlatformLocation` transparently.
useExisting: SingleSpaPlatformLocation,
},
];
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 1fd695fb..af7066c8 100644
--- a/schematics/ng-add/_files/src/main.single-spa.ts.template
+++ b/schematics/ng-add/_files/src/main.single-spa.ts.template
@@ -3,8 +3,8 @@
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';<% if (routing) { %>
import { Router, NavigationStart } from '@angular/router';<% } %>
-<% if (routing) { %>import { singleSpaAngular, getSingleSpaExtraProviders } from 'single-spa-angular';
-<% } else { %>import { singleSpaAngular } from 'single-spa-angular';<% } %>
+<% if (routing) { %>import { singleSpaAngular, getSingleSpaExtraProviders } from '@single-spa-community/angular';
+<% } else { %>import { singleSpaAngular } from '@single-spa-community/angular';<% } %>
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
diff --git a/system/rollup.config.js b/system/rollup.config.js
index a6d5f6a7..07583f37 100644
--- a/system/rollup.config.js
+++ b/system/rollup.config.js
@@ -26,23 +26,23 @@ const packages = ['2022']
.map(ecma => [
{
ecma,
- angularPackage: 'single-spa-angular/internals',
- filename: 'single-spa-angular-internals',
+ angularPackage: '@single-spa-community/angular/internals',
+ filename: 'single-spa-community-angular-internals',
},
{
ecma,
- angularPackage: 'single-spa-angular',
- filename: 'single-spa-angular',
+ angularPackage: '@single-spa-community/angular',
+ filename: 'single-spa-community-angular',
},
{
ecma,
- angularPackage: 'single-spa-angular/elements',
- filename: 'single-spa-angular-elements',
+ angularPackage: '@single-spa-community/angular/elements',
+ filename: 'single-spa-community-angular-elements',
},
{
ecma,
- angularPackage: 'single-spa-angular/parcel',
- filename: 'single-spa-angular-parcel',
+ angularPackage: '@single-spa-community/angular/parcel',
+ filename: 'single-spa-community-angular-parcel',
},
])
.flat();
@@ -97,7 +97,7 @@ function createConfig({ ecma, prod, format, filename }) {
'rxjs/operators',
'@angular/core',
'@angular/common',
- 'single-spa-angular/internals',
+ '@single-spa-community/angular/internals',
],
};
}