Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 6 additions & 1 deletion apps/chat/src/app/app.config.ts
Original file line number Diff line number Diff line change
@@ -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),
],
};
4 changes: 2 additions & 2 deletions apps/chat/src/main.single-spa.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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(() => {
Expand Down
4 changes: 2 additions & 2 deletions apps/elements/src/main.single-spa.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -12,7 +12,7 @@ const lifecycles = singleSpaAngularElements({
bootstrapFunction: async () => {
unmountableStyles.use();

const ngModuleRef = await platformBrowser(getSingleSpaExtraProviders()).bootstrapModule(
const ngModuleRef = await platformBrowser(provideSingleSpaPlatform()).bootstrapModule(
AppModule,
);

Expand Down
3 changes: 3 additions & 0 deletions apps/navbar/src/app/app.config.ts
Original file line number Diff line number Diff line change
@@ -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: '/',
Expand Down
4 changes: 2 additions & 2 deletions apps/navbar/src/main.single-spa.ts
Original file line number Diff line number Diff line change
@@ -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: '<navbar-root />',
Expand Down
4 changes: 2 additions & 2 deletions apps/parcel/src/main.single-spa.ts
Original file line number Diff line number Diff line change
@@ -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,
{
Expand Down
7 changes: 6 additions & 1 deletion apps/shop/src/app/app.config.ts
Original file line number Diff line number Diff line change
@@ -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),
],
};
4 changes: 2 additions & 2 deletions apps/shop/src/main.single-spa.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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: '<shop-root />',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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: '<app-root />',
Expand All @@ -133,7 +123,7 @@ export class SingleSpaPlatformLocation extends BrowserPlatformLocation {
* NavigationStart,
* });
*/
export function getSingleSpaExtraProviders(): StaticProvider[] {
export function provideSingleSpaPlatform(): StaticProvider[] {
return [
{
provide: SingleSpaPlatformLocation,
Expand Down
49 changes: 49 additions & 0 deletions libs/single-spa-community-angular/src/providers.ts
Original file line number Diff line number Diff line change
@@ -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)),
},
]);
}
3 changes: 2 additions & 1 deletion libs/single-spa-community-angular/src/public_api.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { singleSpaAngular } from './single-spa-angular';
export { getSingleSpaExtraProviders } from './extra-providers';
export { provideSingleSpaPlatform } from './platform-providers';
export { provideSingleSpa } from './providers';
10 changes: 5 additions & 5 deletions libs/single-spa-community-angular/src/single-spa-angular.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
Expand All @@ -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()?
`);
}

Expand All @@ -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`
Expand Down
4 changes: 2 additions & 2 deletions schematics/ng-add/_files/src/main.single-spa.ts.template
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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();
<% } %>
Expand Down
Loading