From 0011843dd1ba098d547ab22d39239e617a6be6db Mon Sep 17 00:00:00 2001 From: arturovt Date: Thu, 26 Feb 2026 00:56:49 +0200 Subject: [PATCH] feat: rename getSingleSpaExtraProviders to provideSingleSpaPlatform & add `provideSingleSpa()` BREAKING CHANGE: getSingleSpaExtraProviders has been renamed to provideSingleSpaPlatform to better align with Angular's naming conventions for provider functions. Before: const platformRef = platformBrowser(getSingleSpaExtraProviders()); After: const platformRef = platformBrowser(provideSingleSpaPlatform()); Also added provideSingleSpa, a new environment provider to be used at the application level (e.g. in bootstrapApplication or an NgModule). --- README.md | 2 + apps/chat/src/app/app.config.ts | 7 ++- apps/chat/src/main.single-spa.ts | 4 +- apps/elements/src/main.single-spa.ts | 4 +- apps/navbar/src/app/app.config.ts | 3 ++ apps/navbar/src/main.single-spa.ts | 4 +- apps/parcel/src/main.single-spa.ts | 4 +- apps/shop/src/app/app.config.ts | 7 ++- apps/shop/src/main.single-spa.ts | 4 +- ...tra-providers.ts => platform-providers.ts} | 14 +----- .../src/providers.ts | 49 +++++++++++++++++++ .../src/public_api.ts | 3 +- .../src/single-spa-angular.ts | 10 ++-- .../_files/src/main.single-spa.ts.template | 4 +- 14 files changed, 87 insertions(+), 32 deletions(-) rename libs/single-spa-community-angular/src/{extra-providers.ts => platform-providers.ts} (88%) create mode 100644 libs/single-spa-community-angular/src/providers.ts 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(); <% } %>