diff --git a/README.md b/README.md
index 811edac1..ede92504 100644
--- a/README.md
+++ b/README.md
@@ -27,3 +27,5 @@ The package has been republished under the `@single-spa-community` scope due to
| `21.x` | `21.x` |
| `20.x` | `20.x` |
| `19.x` | `19.x` |
+
+> **Note:** This package follows a lock-step versioning strategy — the major version is aligned with Angular's major version to make compatibility obvious at a glance. This means a major bump does not necessarily indicate a breaking change in this package's own API.
diff --git a/apps/chat/src/app/app.config.ts b/apps/chat/src/app/app.config.ts
index bfd3dcf3..17200bcf 100644
--- a/apps/chat/src/app/app.config.ts
+++ b/apps/chat/src/app/app.config.ts
@@ -1,9 +1,14 @@
import { APP_BASE_HREF } from '@angular/common';
import type { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
+import { provideSingleSpa } from '@single-spa-community/angular';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
- providers: [{ provide: APP_BASE_HREF, useValue: '/chat' }, provideRouter(routes)],
+ providers: [
+ provideSingleSpa(),
+ { provide: APP_BASE_HREF, useValue: '/chat' },
+ provideRouter(routes),
+ ],
};
diff --git a/apps/chat/src/main.single-spa.ts b/apps/chat/src/main.single-spa.ts
index 6961460a..e03b80d1 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-community/angular';
+import { provideSingleSpaPlatform, singleSpaAngular } from '@single-spa-community/angular';
import { singleSpaPropsSubject } from './single-spa/single-spa-props';
import { bootstrapApplication, platformBrowser } from '@angular/platform-browser';
@@ -8,7 +8,7 @@ import { appConfig } from './app/app.config';
const lifecycles = singleSpaAngular({
bootstrapFunction: async singleSpaProps => {
- const platformRef = platformBrowser(getSingleSpaExtraProviders());
+ const platformRef = platformBrowser(provideSingleSpaPlatform());
singleSpaPropsSubject.next(singleSpaProps);
const appRef = await bootstrapApplication(AppComponent, appConfig, { platformRef });
appRef.onDestroy(() => {
diff --git a/apps/elements/src/main.single-spa.ts b/apps/elements/src/main.single-spa.ts
index 26b1e1dc..2feb27f9 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-community/angular/elements';
-import { getSingleSpaExtraProviders } from '@single-spa-community/angular';
+import { provideSingleSpaPlatform } from '@single-spa-community/angular';
import { AppModule } from './app/app.module';
@@ -12,7 +12,7 @@ const lifecycles = singleSpaAngularElements({
bootstrapFunction: async () => {
unmountableStyles.use();
- const ngModuleRef = await platformBrowser(getSingleSpaExtraProviders()).bootstrapModule(
+ const ngModuleRef = await platformBrowser(provideSingleSpaPlatform()).bootstrapModule(
AppModule,
);
diff --git a/apps/navbar/src/app/app.config.ts b/apps/navbar/src/app/app.config.ts
index d7a0f458..2c0aaaea 100644
--- a/apps/navbar/src/app/app.config.ts
+++ b/apps/navbar/src/app/app.config.ts
@@ -1,11 +1,14 @@
import { APP_BASE_HREF } from '@angular/common';
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
+import { provideSingleSpa } from '@single-spa-community/angular';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
+ provideSingleSpa(),
+
{
provide: APP_BASE_HREF,
useValue: '/',
diff --git a/apps/navbar/src/main.single-spa.ts b/apps/navbar/src/main.single-spa.ts
index 859e1d50..9d3917aa 100644
--- a/apps/navbar/src/main.single-spa.ts
+++ b/apps/navbar/src/main.single-spa.ts
@@ -1,13 +1,13 @@
import { NavigationStart, Router } from '@angular/router';
import { bootstrapApplication, platformBrowser } from '@angular/platform-browser';
-import { getSingleSpaExtraProviders, singleSpaAngular } from '@single-spa-community/angular';
+import { provideSingleSpaPlatform, singleSpaAngular } from '@single-spa-community/angular';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
const lifecycles = singleSpaAngular({
bootstrapFunction: () => {
- const platformRef = platformBrowser(getSingleSpaExtraProviders());
+ const platformRef = platformBrowser(provideSingleSpaPlatform());
return bootstrapApplication(AppComponent, appConfig, { platformRef });
},
template: '',
diff --git a/apps/parcel/src/main.single-spa.ts b/apps/parcel/src/main.single-spa.ts
index 064fb50e..21361b22 100644
--- a/apps/parcel/src/main.single-spa.ts
+++ b/apps/parcel/src/main.single-spa.ts
@@ -1,11 +1,11 @@
import { bootstrapApplication, platformBrowser } from '@angular/platform-browser';
-import { getSingleSpaExtraProviders, singleSpaAngular } from '@single-spa-community/angular';
+import { provideSingleSpaPlatform, singleSpaAngular } from '@single-spa-community/angular';
import { AppComponent } from './app/app.component';
const lifecycles = singleSpaAngular({
bootstrapFunction: () => {
- const platformRef = platformBrowser(getSingleSpaExtraProviders());
+ const platformRef = platformBrowser(provideSingleSpaPlatform());
return bootstrapApplication(
AppComponent,
{
diff --git a/apps/shop/src/app/app.config.ts b/apps/shop/src/app/app.config.ts
index 80821616..a960c19f 100644
--- a/apps/shop/src/app/app.config.ts
+++ b/apps/shop/src/app/app.config.ts
@@ -1,9 +1,14 @@
import { APP_BASE_HREF } from '@angular/common';
import type { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
+import { provideSingleSpa } from '@single-spa-community/angular';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
- providers: [{ provide: APP_BASE_HREF, useValue: '/shop' }, provideRouter(routes)],
+ providers: [
+ provideSingleSpa(),
+ { provide: APP_BASE_HREF, useValue: '/shop' },
+ provideRouter(routes),
+ ],
};
diff --git a/apps/shop/src/main.single-spa.ts b/apps/shop/src/main.single-spa.ts
index 9f2ff13f..bf184939 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-community/angular';
+import { singleSpaAngular, provideSingleSpaPlatform } from '@single-spa-community/angular';
import { loadMontserrat } from './fonts';
import { appConfig } from './app/app.config';
@@ -9,7 +9,7 @@ import { AppComponent } from './app/app.component';
const lifecycles = singleSpaAngular({
bootstrapFunction: async () => {
await loadMontserrat();
- const platformRef = platformBrowser(getSingleSpaExtraProviders());
+ const platformRef = platformBrowser(provideSingleSpaPlatform());
return bootstrapApplication(AppComponent, appConfig, { platformRef });
},
template: '',
diff --git a/libs/single-spa-community-angular/src/extra-providers.ts b/libs/single-spa-community-angular/src/platform-providers.ts
similarity index 88%
rename from libs/single-spa-community-angular/src/extra-providers.ts
rename to libs/single-spa-community-angular/src/platform-providers.ts
index f281bec9..6ca0f140 100644
--- a/libs/single-spa-community-angular/src/extra-providers.ts
+++ b/libs/single-spa-community-angular/src/platform-providers.ts
@@ -10,8 +10,6 @@ import { switchMap } from 'rxjs/operators';
import { runOutsideAngular } from './run-outside-angular';
-declare const Zone: any;
-
export class SingleSpaPlatformLocation extends BrowserPlatformLocation {
// 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
@@ -95,14 +93,6 @@ export class SingleSpaPlatformLocation extends BrowserPlatformLocation {
}
onPopState(fn: LocationChangeListener): VoidFunction {
- // 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) => {
// 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.
@@ -124,7 +114,7 @@ export class SingleSpaPlatformLocation extends BrowserPlatformLocation {
* @example
* const lifecycles = singleSpaAngular({
* bootstrapFunction: async () => {
- * const platformRef = platformBrowser(getSingleSpaExtraProviders());
+ * const platformRef = platformBrowser(provideSingleSpaPlatform());
* return bootstrapApplication(AppComponent, appConfig, { platformRef });
* },
* template: '',
@@ -133,7 +123,7 @@ export class SingleSpaPlatformLocation extends BrowserPlatformLocation {
* NavigationStart,
* });
*/
-export function getSingleSpaExtraProviders(): StaticProvider[] {
+export function provideSingleSpaPlatform(): StaticProvider[] {
return [
{
provide: SingleSpaPlatformLocation,
diff --git a/libs/single-spa-community-angular/src/providers.ts b/libs/single-spa-community-angular/src/providers.ts
new file mode 100644
index 00000000..9944b1e7
--- /dev/null
+++ b/libs/single-spa-community-angular/src/providers.ts
@@ -0,0 +1,49 @@
+import { Location, LocationStrategy, PopStateEvent } from '@angular/common';
+import { inject, makeEnvironmentProviders, NgZone } from '@angular/core';
+import { SubscriptionLike } from 'rxjs';
+
+/**
+ * A custom Angular Location service designed for use in single-spa micro-frontend environments.
+ *
+ * Problem: When multiple Angular applications share a single browser platform, popstate events
+ * are dispatched inside whichever NgZone was active when the platform-level listener was first
+ * registered (typically the zone of the first bootstrapped app). This means route changes
+ * triggered in one micro-frontend can go undetected by another app's change detection.
+ *
+ * Solution: Wrap every popstate callback in the current app's NgZone so that Angular's
+ * change detection is always triggered in the correct zone, regardless of which app
+ * originally registered the platform listener.
+ */
+export class SingleSpaLocation extends Location {
+ private readonly ngZone = inject(NgZone);
+
+ override subscribe(
+ onNext: (value: PopStateEvent) => void,
+ onThrow?: ((exception: any) => void) | null,
+ onReturn?: (() => void) | null,
+ ): SubscriptionLike {
+ // Re-enter this app's NgZone before invoking the callback.
+ // Without this, the callback may run inside a foreign zone (belonging to another
+ // micro-frontend), causing change detection to be skipped for this application.
+ return super.subscribe(value => this.ngZone.run(() => onNext(value)), onThrow, onReturn);
+ }
+}
+
+/**
+ * Provides the single-spa-aware Location service for an Angular micro-frontend.
+ * Add this to your application's providers (e.g. in `bootstrapApplication` or an `NgModule`).
+ *
+ * @example
+ * bootstrapApplication(AppComponent, {
+ * providers: [provideSingleSpa()]
+ * });
+ */
+export function provideSingleSpa() {
+ return makeEnvironmentProviders([
+ {
+ // Replace the default Angular Location with our zone-aware implementation.
+ provide: Location,
+ useFactory: () => new SingleSpaLocation(inject(LocationStrategy)),
+ },
+ ]);
+}
diff --git a/libs/single-spa-community-angular/src/public_api.ts b/libs/single-spa-community-angular/src/public_api.ts
index cc41af60..48c0d5cb 100644
--- a/libs/single-spa-community-angular/src/public_api.ts
+++ b/libs/single-spa-community-angular/src/public_api.ts
@@ -1,2 +1,3 @@
export { singleSpaAngular } from './single-spa-angular';
-export { getSingleSpaExtraProviders } from './extra-providers';
+export { provideSingleSpaPlatform } from './platform-providers';
+export { provideSingleSpa } from './providers';
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 5f4a345d..9d74eb9a 100644
--- a/libs/single-spa-community-angular/src/single-spa-angular.ts
+++ b/libs/single-spa-community-angular/src/single-spa-angular.ts
@@ -2,7 +2,7 @@ import type { ApplicationRef, NgModuleRef, NgZone } from '@angular/core';
import type { LifeCycles } from 'single-spa';
import { getContainerElementAndSetTemplate } from '@single-spa-community/angular/internals';
-import { SingleSpaPlatformLocation } from './extra-providers';
+import { SingleSpaPlatformLocation } from './platform-providers';
import type { SingleSpaAngularOptions, BootstrappedSingleSpaAngularOptions } from './types';
const defaultOptions = {
@@ -106,7 +106,7 @@ async function mount(
const singleSpaPlatformLocation = bootstrappedRef.injector.get(SingleSpaPlatformLocation, null);
- // `getSingleSpaExtraProviders()` must be passed to `platformBrowser()` when the application
+ // `provideSingleSpaPlatform()` 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,
@@ -116,11 +116,11 @@ async function mount(
// 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()`
+ // platform injector, it means `provideSingleSpaPlatform()` 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 add getSingleSpaExtraProviders()?
+ single-spa-angular: could not retrieve extra providers from the platform injector. Did you add provideSingleSpaPlatform()?
`);
}
@@ -130,7 +130,7 @@ async function mount(
const ngZone: NgZone = bootstrappedRef.injector.get(options.NgZone);
// 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()`
+ // its own navigation or has no routing at all). In that case, `provideSingleSpaPlatform()`
// 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`
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 88c04313..3899d02e 100644
--- a/schematics/ng-add/_files/src/main.single-spa.ts.template
+++ b/schematics/ng-add/_files/src/main.single-spa.ts.template
@@ -1,7 +1,7 @@
<% if (routing) { %>
import { bootstrapApplication, platformBrowser } from '@angular/platform-browser';
import { NavigationStart, Router } from '@angular/router';
-import { singleSpaAngular, getSingleSpaExtraProviders } from '@single-spa-community/angular'
+import { singleSpaAngular, provideSingleSpaPlatform } from '@single-spa-community/angular'
<% } else { %>
import { bootstrapApplication, platformBrowser } from '@angular/platform-browser';
import { singleSpaAngular } from '@single-spa-community/angular'
@@ -15,7 +15,7 @@ const lifecycles = singleSpaAngular({
bootstrapFunction: singleSpaProps => {
singleSpaProps.set(singleSpaProps);
<% if (routing) { %>
- const platformRef = platformBrowser(getSingleSpaExtraProviders());
+ const platformRef = platformBrowser(provideSingleSpaPlatform());
<% } else { %>
const platformRef = platformBrowser();
<% } %>