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', ], }; }