diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..b386e130 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "files.associations": { + "*.template": "ejs" + } +} diff --git a/apps/chat/src/main.single-spa.ts b/apps/chat/src/main.single-spa.ts index e840c83c..6961460a 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-angular'; +import { getSingleSpaExtraProviders, singleSpaAngular } from '@single-spa-community/angular'; import { singleSpaPropsSubject } from './single-spa/single-spa-props'; import { bootstrapApplication, platformBrowser } from '@angular/platform-browser'; diff --git a/apps/elements/src/main.single-spa.ts b/apps/elements/src/main.single-spa.ts index 4c659831..26b1e1dc 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-angular/elements'; -import { getSingleSpaExtraProviders } from 'single-spa-angular'; +import { singleSpaAngularElements } from '@single-spa-community/angular/elements'; +import { getSingleSpaExtraProviders } from '@single-spa-community/angular'; import { AppModule } from './app/app.module'; diff --git a/apps/navbar/src/main.single-spa.ts b/apps/navbar/src/main.single-spa.ts index 0f62a51e..859e1d50 100644 --- a/apps/navbar/src/main.single-spa.ts +++ b/apps/navbar/src/main.single-spa.ts @@ -1,6 +1,6 @@ import { NavigationStart, Router } from '@angular/router'; import { bootstrapApplication, platformBrowser } from '@angular/platform-browser'; -import { getSingleSpaExtraProviders, singleSpaAngular } from 'single-spa-angular'; +import { getSingleSpaExtraProviders, singleSpaAngular } from '@single-spa-community/angular'; import { appConfig } from './app/app.config'; import { AppComponent } from './app/app.component'; diff --git a/apps/parcel/src/app/app.component.html b/apps/parcel/src/app/app.component.html index 20fc31de..58b448c5 100644 --- a/apps/parcel/src/app/app.component.html +++ b/apps/parcel/src/app/app.component.html @@ -1,4 +1,6 @@ @let _mountRootParcel = mountRootParcel(); +@let _customProps = customProps(); @if (_mountRootParcel) { - + + } diff --git a/apps/parcel/src/app/app.component.ts b/apps/parcel/src/app/app.component.ts index 8d79aa8e..473c4226 100644 --- a/apps/parcel/src/app/app.component.ts +++ b/apps/parcel/src/app/app.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { defer, shareReplay } from 'rxjs'; -import { ParcelModule } from 'single-spa-angular/parcel'; +import { ParcelModule } from '@single-spa-community/angular/parcel'; import { config } from './ReactWidget/ReactWidget'; @@ -16,15 +16,21 @@ const singleSpa$ = defer(() => System.import('single-spa')).pipe( imports: [ParcelModule], }) export class AppComponent { - config = config; + readonly config = config; readonly mountRootParcel = signal(null); - customProps = { + readonly customProps = signal>({ hello: 'Hola', - }; + }); constructor() { singleSpa$.pipe(takeUntilDestroyed()).subscribe(({ mountRootParcel }) => { this.mountRootParcel.set(mountRootParcel); }); } + + updateCustomProps(): void { + this.customProps.set({ + hello: 'Bonjour', + }); + } } diff --git a/apps/parcel/src/main.single-spa.ts b/apps/parcel/src/main.single-spa.ts index 7493cf69..064fb50e 100644 --- a/apps/parcel/src/main.single-spa.ts +++ b/apps/parcel/src/main.single-spa.ts @@ -1,5 +1,5 @@ import { bootstrapApplication, platformBrowser } from '@angular/platform-browser'; -import { getSingleSpaExtraProviders, singleSpaAngular } from 'single-spa-angular'; +import { getSingleSpaExtraProviders, singleSpaAngular } from '@single-spa-community/angular'; import { AppComponent } from './app/app.component'; diff --git a/apps/root-config/src/index.ejs b/apps/root-config/src/index.ejs index 67ffa6c7..ec468c80 100644 --- a/apps/root-config/src/index.ejs +++ b/apps/root-config/src/index.ejs @@ -53,10 +53,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/shop/src/main.single-spa.ts b/apps/shop/src/main.single-spa.ts index cb19380c..9f2ff13f 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-angular'; +import { singleSpaAngular, getSingleSpaExtraProviders } from '@single-spa-community/angular'; import { loadMontserrat } from './fonts'; import { appConfig } from './app/app.config'; diff --git a/cypress/integration/parcel.spec.js b/cypress/integration/parcel.spec.js index 193fc7b7..2a3b0eed 100644 --- a/cypress/integration/parcel.spec.js +++ b/cypress/integration/parcel.spec.js @@ -5,7 +5,7 @@ Cypress.Screenshot.defaults({ }); describe('Angular parcel', () => { - it('should navigate to /parcel and render the React widget and lazy component', () => { + it('should navigate to /parcel and render the React widget and lazy component AND update custom props', () => { cy.visit('/parcel') // GitHub Actions CI is not as fast as the local setup, there can be some network delays, // timeout is used only for this purpose. @@ -15,6 +15,10 @@ describe('Angular parcel', () => { .invoke('attr', 'alt') .should('eq', 'React logo') .get('parcel-root parcel h1') - .contains('Hola world'); + .contains('Hola world') + .get('#update-custom-props') + .click() + .get('parcel-root parcel h1') + .contains('Bonjour world'); }); }); diff --git a/libs/single-spa-community-angular/internals/src/dom.ts b/libs/single-spa-community-angular/internals/src/dom.ts index 2d7e2204..b05ad9b5 100644 --- a/libs/single-spa-community-angular/internals/src/dom.ts +++ b/libs/single-spa-community-angular/internals/src/dom.ts @@ -1,16 +1,12 @@ import { DomElementGetter, BaseSingleSpaAngularOptions } from './types'; -// This will be provided through Terser global definitions by Angular CLI. This will -// help to tree-shake away the code unneeded for production bundles. -declare const ngDevMode: boolean; - export function getContainerElementAndSetTemplate( options: T, props: any, ): HTMLElement { const domElementGetter = chooseDomElementGetter(options, props); - if ((typeof ngDevMode === 'undefined' || ngDevMode) && !domElementGetter) { + if (!domElementGetter) { throw Error( `Cannot mount angular application '${ props.name || props.appName @@ -26,7 +22,7 @@ export function getContainerElementAndSetTemplate(null); @@ -47,14 +42,19 @@ export class ParcelComponent { const customProps = this.customProps(); untracked(() => { this.scheduleTask(Action.Update, () => { - this.parcel?.update?.(customProps); + this.parcel?.update?.({ + ...customProps, + domElement: this.wrapper, + }); }); }); }); afterNextRender(() => { this.scheduleTask(Action.Mount, () => { - if ((typeof ngDevMode === 'undefined' || ngDevMode) && this.mountParcel === null) { + const mountParcel = this.mountParcel(); + + if (mountParcel === null) { throw new Error( 'single-spa-angular: the [mountParcel] binding is required when using the component. You can either (1) import mountRootParcel from single-spa or (2) use the mountParcel prop provided to single-spa applications.', ); @@ -69,8 +69,7 @@ export class ParcelComponent { this.host.nativeElement.appendChild(this.wrapper); } - const mountParcel = this.mountParcel(); - this.parcel = mountParcel!(this.config()!, { + this.parcel = mountParcel(this.config()!, { ...this.customProps(), domElement: this.wrapper, }); @@ -88,7 +87,9 @@ export class ParcelComponent { inject(DestroyRef).onDestroy(() => { this.scheduleTask(Action.Unmount, () => { if (this.parcel?.getStatus() === 'MOUNTED') { - return this.parcel.unmount(); + return this.parcel.unmount().then(() => { + this.parcel = null; + }); } }); diff --git a/libs/single-spa-community-angular/src/extra-providers.ts b/libs/single-spa-community-angular/src/extra-providers.ts index bfb7a348..f281bec9 100644 --- a/libs/single-spa-community-angular/src/extra-providers.ts +++ b/libs/single-spa-community-angular/src/extra-providers.ts @@ -5,8 +5,10 @@ import { type LocationChangeEvent, type LocationChangeListener, } from '@angular/common'; -import { asyncScheduler, Subject, timer } from 'rxjs'; -import { switchMap, map } from 'rxjs/operators'; +import { Observable, Subject } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { runOutsideAngular } from './run-outside-angular'; declare const Zone: any; @@ -39,13 +41,21 @@ export class SingleSpaPlatformLocation extends BrowserPlatformLocation { // 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 => - // `timer(0, asyncScheduler)` defers execution to the next macrotask (via setTimeout). - // 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. - timer(0, asyncScheduler).pipe(map(() => state)), + 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]) => { diff --git a/libs/single-spa-community-angular/src/prod-mode.ts b/libs/single-spa-community-angular/src/prod-mode.ts deleted file mode 100644 index d1bcae0e..00000000 --- a/libs/single-spa-community-angular/src/prod-mode.ts +++ /dev/null @@ -1,11 +0,0 @@ -import * as core from '@angular/core'; - -export function enableProdMode(): void { - try { - // The `enableProdMode` will throw an error if it's called multiple times, - // but it may be called multiple times when dependencies are shared. - core.enableProdMode(); - } catch { - // Nothing to do here. - } -} diff --git a/libs/single-spa-community-angular/src/public_api.ts b/libs/single-spa-community-angular/src/public_api.ts index 6c12af42..cc41af60 100644 --- a/libs/single-spa-community-angular/src/public_api.ts +++ b/libs/single-spa-community-angular/src/public_api.ts @@ -1,3 +1,2 @@ -export { enableProdMode } from './prod-mode'; export { singleSpaAngular } from './single-spa-angular'; export { getSingleSpaExtraProviders } from './extra-providers'; diff --git a/libs/single-spa-community-angular/src/run-outside-angular.ts b/libs/single-spa-community-angular/src/run-outside-angular.ts new file mode 100644 index 00000000..def90c55 --- /dev/null +++ b/libs/single-spa-community-angular/src/run-outside-angular.ts @@ -0,0 +1,7 @@ +declare const Zone: any; + +export function runOutsideAngular(fn: () => T): T { + return typeof Zone !== 'undefined' && typeof Zone?.root?.run === 'function' + ? Zone.root.run(fn) + : fn(); +} 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 94a0c7f8..5f4a345d 100644 --- a/libs/single-spa-community-angular/src/single-spa-angular.ts +++ b/libs/single-spa-community-angular/src/single-spa-angular.ts @@ -14,17 +14,11 @@ const defaultOptions = { Router: undefined, domElementGetter: undefined, // only optional if you provide a domElementGetter as a custom prop updateFunction: () => Promise.resolve(), - bootstrappedNgModuleRefOrAppRef: null, + bootstrappedRef: null, }; -// This will be provided through Terser global definitions by Angular CLI. This will -// help to tree-shake away the code unneeded for production bundles. -declare const ngDevMode: boolean; - -const NG_DEV_MODE = typeof ngDevMode === 'undefined' || ngDevMode; - export function singleSpaAngular(userOptions: SingleSpaAngularOptions): LifeCycles { - if (NG_DEV_MODE && typeof userOptions !== 'object') { + if (typeof userOptions !== 'object') { throw Error('single-spa-angular requires a configuration object'); } @@ -33,19 +27,19 @@ export function singleSpaAngular(userOptions: SingleSpaAngularOptions): Li ...userOptions, }; - if (NG_DEV_MODE && typeof options.bootstrapFunction !== 'function') { + if (typeof options.bootstrapFunction !== 'function') { throw Error('single-spa-angular must be passed an options.bootstrapFunction'); } - if (NG_DEV_MODE && typeof options.template !== 'string') { + if (typeof options.template !== 'string') { throw Error('single-spa-angular must be passed options.template string'); } - if (NG_DEV_MODE && !options.NgZone) { + if (!options.NgZone) { throw Error(`single-spa-angular must be passed the NgZone option`); } - if (NG_DEV_MODE && options.Router && !options.NavigationStart) { + if (options.Router && !options.NavigationStart) { // We call `console.warn` except of throwing `new Error()` since this will not // be a breaking change. console.warn(`single-spa-angular must be passed the NavigationStart option`); @@ -60,27 +54,31 @@ export function singleSpaAngular(userOptions: SingleSpaAngularOptions): Li } async function bootstrap(options: BootstrappedSingleSpaAngularOptions): Promise { - // Angular provides an opportunity to develop `zone-less` application, where developers - // have to trigger change detection manually. - // See https://angular.io/guide/zone#noopzone if (options.NgZone === 'noop') { return; } - // Note that we have to make it a noop function because it's a static property and not - // an instance property. We're unable to configure it for multiple apps when dependencies - // are shared and reference the same `NgZone` class. We can't determine where this function - // is being executed or under which application, making it difficult to assert whether this - // app is running under its zone. + // `NgZone.assertInAngularZone` and `NgZone.assertNotInAngularZone` are static methods, + // meaning they are shared across all instances of `NgZone`. When multiple Angular apps + // share dependencies (i.e. the same `NgZone` class reference), these assertions become + // unreliable because they cannot distinguish which application's zone is currently active. + // For example, app A's zone could be active while app B's assertion fires, causing false + // negatives. To avoid misleading errors in a microfrontend environment where multiple + // Angular zones coexist on the same page, we replace both methods with no-ops. options.NgZone.assertInAngularZone = () => {}; options.NgZone.assertNotInAngularZone = () => {}; + // single-spa intercepts browser navigation events (pushState, replaceState, popstate) + // and orchestrates routing across all mounted microfrontends. However, Zone.js is unaware + // of these navigation changes because they happen outside Angular's zone — single-spa + // dispatches its own routing events rather than going through Angular's router lifecycle. + // As a result, Angular's change detection is never triggered after a single-spa navigation. + // To fix this, we register a routing event listener that explicitly re-enters the app's + // Angular zone via `NgZone.run()`, which signals to Angular that something has changed + // and change detection should run. + // See https://github.com/single-spa/single-spa-angular/issues/86 options.routingEventListener = () => { - options.bootstrappedNgZone!.run(() => { - // See https://github.com/single-spa/single-spa-angular/issues/86 - // Zone is unaware of the single-spa navigation change and so Angular change detection doesn't work - // unless we tell Zone that something happened - }); + options.bootstrappedNgZone!.run(() => {}); }; } @@ -92,56 +90,62 @@ async function mount( const bootstrapPromise = options.bootstrapFunction(props); - if (NG_DEV_MODE && !(bootstrapPromise instanceof Promise)) { + if (!(bootstrapPromise instanceof Promise)) { throw Error( `single-spa-angular: the options.bootstrapFunction must return a promise, but instead returned a '${typeof bootstrapPromise}' that is not a Promise`, ); } - const ngModuleRefOrAppRef: NgModuleRef | ApplicationRef = await bootstrapPromise; + const bootstrappedRef = await bootstrapPromise; - if (NG_DEV_MODE) { - if (!ngModuleRefOrAppRef || typeof ngModuleRefOrAppRef.destroy !== 'function') { - throw Error( - `single-spa-angular: the options.bootstrapFunction returned a promise that did not resolve with a valid Angular module or ApplicationRef. Did you call platformBrowserDynamic().bootstrapModule() correctly?`, - ); - } + if (typeof bootstrappedRef?.destroy !== 'function') { + throw Error( + `single-spa-angular: the options.bootstrapFunction returned a promise that did not resolve with a valid Angular module or ApplicationRef. Did you call platformBrowserDynamic().bootstrapModule() correctly?`, + ); } - const singleSpaPlatformLocation = ngModuleRefOrAppRef.injector.get( - SingleSpaPlatformLocation, - null, - ); - - const ngZoneEnabled = options.NgZone !== 'noop'; - - // The user has to provide `BrowserPlatformLocation` only if his application uses routing. - // So if he provided `Router` but didn't provide `BrowserPlatformLocation` then we have to inform him. - // Also `getSingleSpaExtraProviders()` function should be called only if the user doesn't use - // `zone-less` change detection, if `NgZone` is `noop` then we can skip it. - if (NG_DEV_MODE && ngZoneEnabled && options.Router && singleSpaPlatformLocation === null) { + const singleSpaPlatformLocation = bootstrappedRef.injector.get(SingleSpaPlatformLocation, null); + + // `getSingleSpaExtraProviders()` 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, + // leading to infinite loops or incorrect routing behavior. + // + // However, if the app is running in zoneless mode (`NgZone: 'noop'`), change detection is + // 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()` + // 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 call platformBrowserDynamic(getSingleSpaExtraProviders()).bootstrapModule()? - `); + single-spa-angular: could not retrieve extra providers from the platform injector. Did you add getSingleSpaExtraProviders()? + `); } const bootstrappedOptions = options as BootstrappedSingleSpaAngularOptions; - if (ngZoneEnabled) { - const ngZone: NgZone = ngModuleRefOrAppRef.injector.get(options.NgZone); + if (options.NgZone !== 'noop') { + const ngZone: NgZone = bootstrappedRef.injector.get(options.NgZone); - // `NgZone` can be enabled but routing may not be used thus `getSingleSpaExtraProviders()` - // function was not called. + // 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()` + // 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` + // relies on it to distinguish synthetic single-spa navigation events from genuine + // browser back/forward navigation. if (singleSpaPlatformLocation !== null) { - skipLocationChangeOnNonImperativeRoutingTriggers(ngModuleRefOrAppRef, options); + skipLocationChangeOnNonImperativeRoutingTriggers(bootstrappedRef, options); } bootstrappedOptions.bootstrappedNgZone = ngZone; window.addEventListener('single-spa:routing-event', bootstrappedOptions.routingEventListener!); } - bootstrappedOptions.bootstrappedNgModuleRefOrAppRef = ngModuleRefOrAppRef; - return ngModuleRefOrAppRef; + bootstrappedOptions.bootstrappedRef = bootstrappedRef; + return bootstrappedRef; } function unmount(options: BootstrappedSingleSpaAngularOptions): Promise { @@ -150,33 +154,44 @@ function unmount(options: BootstrappedSingleSpaAngularOptions): Promise { window.removeEventListener('single-spa:routing-event', options.routingEventListener); } - options.bootstrappedNgModuleRefOrAppRef!.destroy(); - options.bootstrappedNgModuleRefOrAppRef = null; + options.bootstrappedRef!.destroy(); + options.bootstrappedRef = null; }); } function skipLocationChangeOnNonImperativeRoutingTriggers( - ngModuleRefOrAppRef: NgModuleRef | ApplicationRef, + bootstrappedRef: NgModuleRef | ApplicationRef, options: SingleSpaAngularOptions, ): void { const { NavigationStart, Router } = options; if (!NavigationStart || !Router) { - // As discussed we don't do anything right now if the developer doesn't provide - // `options.NavigationStart` since this might be a breaking change. + // `NavigationStart` and `Router` must both be provided in `singleSpaAngular()` options + // for this optimization to work. We intentionally do nothing if they are absent rather + // than throwing, because adding this as a hard requirement would be a breaking change + // for existing users who haven't provided these options. return; } - const router = ngModuleRefOrAppRef.injector.get(Router); + const router = bootstrappedRef.injector.get(Router); const subscription = router.events.subscribe((event: any) => { if (event instanceof NavigationStart) { const currentNavigation = router.getCurrentNavigation(); - // This listener will be set up for each Angular application - // that has routing capabilities. - // We set `skipLocationChange` for each non-imperative navigation, - // Angular router checks under the hood if it has to change - // the browser URL or not. - // If `skipLocationChange` is truthy then Angular router will not call - // `setBrowserUrl()` which calls `history.replaceState()` and dispatches `popstate` event. + + // In a single-spa microfrontend environment, multiple apps share the same browser URL. + // When single-spa triggers a routing change (e.g. via popstate or its own navigation + // events), Angular's router responds and would normally call `setBrowserUrl()` internally, + // which calls `history.replaceState()` and dispatches a new `popstate` event. This creates + // a feedback loop: single-spa triggers Angular, Angular updates the URL, which triggers + // single-spa again, and so on. + // + // To break this cycle, we intercept every non-imperative navigation (i.e. navigations + // triggered by popstate or single-spa routing events, rather than by explicit router.navigate() + // calls in application code) and set `skipLocationChange: true`. This tells Angular's router + // to perform the navigation and update its internal state without calling `history.replaceState()`, + // preventing the redundant popstate event that would otherwise cause the infinite loop. + // + // `replaceUrl: false` is also set to ensure Angular does not attempt to replace the current + // history entry, which would have the same undesirable side effect. if (currentNavigation.trigger !== 'imperative') { currentNavigation.extras.skipLocationChange = true; currentNavigation.extras.replaceUrl = false; @@ -184,5 +199,5 @@ function skipLocationChangeOnNonImperativeRoutingTriggers( } }); - ngModuleRefOrAppRef.onDestroy(() => subscription.unsubscribe()); + bootstrappedRef.onDestroy(() => subscription.unsubscribe()); } diff --git a/libs/single-spa-community-angular/src/types.ts b/libs/single-spa-community-angular/src/types.ts index e8aa3817..56de7455 100644 --- a/libs/single-spa-community-angular/src/types.ts +++ b/libs/single-spa-community-angular/src/types.ts @@ -16,7 +16,7 @@ export interface SingleSpaAngularOptions< } export interface BootstrappedSingleSpaAngularOptions extends SingleSpaAngularOptions { - bootstrappedNgModuleRefOrAppRef: NgModuleRef | ApplicationRef | null; + bootstrappedRef: NgModuleRef | ApplicationRef | null; // All below properties can be optional in case of // `SingleSpaAngularOpts.NgZone` is a `noop` string and not an `NgZone` class. bootstrappedNgZone?: NgZone; diff --git a/libs/single-spa-community-angular/webpack/externals.ts b/libs/single-spa-community-angular/webpack/externals.ts index bc2637ee..52e01844 100644 --- a/libs/single-spa-community-angular/webpack/externals.ts +++ b/libs/single-spa-community-angular/webpack/externals.ts @@ -17,12 +17,15 @@ export const externals = [ '@angular/core/primitives/signals', '@angular/core/primitives/event-dispatch', + '@angular/core/primitives/di', '@angular/core', '@angular/core/rxjs-interop', '@angular/elements', '@angular/forms', + '@angular/forms/signals', + '@angular/forms/signals/compat', '@angular/localize', '@angular/localize/init', @@ -43,8 +46,8 @@ export const externals = [ '@angular/upgrade/static', 'single-spa', - 'single-spa-angular/internals', - 'single-spa-angular', - 'single-spa-angular/elements', - 'single-spa-angular/parcel', + '@single-spa-community/angular/internals', + '@single-spa-community/angular', + '@single-spa-community/angular/elements', + '@single-spa-community/angular/parcel', ]; diff --git a/schematics/ng-add/_files/extra-webpack.config.js.template b/schematics/ng-add/_files/extra-webpack.config.js.template index 73a1ee2a..63e987a6 100644 --- a/schematics/ng-add/_files/extra-webpack.config.js.template +++ b/schematics/ng-add/_files/extra-webpack.config.js.template @@ -1,4 +1,4 @@ -const singleSpaAngularWebpack = require('single-spa-angular/lib/webpack').default; +const singleSpaAngularWebpack = require('@single-spa-community/angular/lib/webpack').default; module.exports = (config, options) => { const singleSpaWebpackConfig = singleSpaAngularWebpack(config, options); 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 c949ce10..88c04313 100644 --- a/schematics/ng-add/_files/src/main.single-spa.ts.template +++ b/schematics/ng-add/_files/src/main.single-spa.ts.template @@ -1,25 +1,30 @@ -<% if (routing) { %>import { enableProdMode } from '@angular/core';<% } %> -<% if (!routing) { %>import { enableProdMode } from '@angular/core';<% } %> -import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';<% if (routing) { %> -import { Router, NavigationStart } from '@angular/router';<% } %> +<% if (routing) { %> +import { bootstrapApplication, platformBrowser } from '@angular/platform-browser'; +import { NavigationStart, Router } from '@angular/router'; +import { singleSpaAngular, getSingleSpaExtraProviders } from '@single-spa-community/angular' +<% } else { %> +import { bootstrapApplication, platformBrowser } from '@angular/platform-browser'; +import { singleSpaAngular } from '@single-spa-community/angular' +<% } %> -<% if (routing) { %>import { singleSpaAngular, getSingleSpaExtraProviders } from 'single-spa-angular'; -<% } else { %>import { singleSpaAngular } from 'single-spa-angular';<% } %> - -import { AppModule } from './app/app.module'; -import { environment } from './environments/environment'; -import { singleSpaPropsSubject } from './single-spa/single-spa-props'; +import { appConfig } from './app/app.config'; +import { AppComponent } from './app/app.component'; +import { singleSpaProps } from './single-spa/single-spa-props'; const lifecycles = singleSpaAngular({ bootstrapFunction: singleSpaProps => { - singleSpaPropsSubject.next(singleSpaProps);<% if (routing) { %> - return platformBrowserDynamic(getSingleSpaExtraProviders()).bootstrapModule(AppModule);<% } else { %> - return platformBrowserDynamic().bootstrapModule(AppModule);<% } %> + singleSpaProps.set(singleSpaProps); + <% if (routing) { %> + const platformRef = platformBrowser(getSingleSpaExtraProviders()); + <% } else { %> + const platformRef = platformBrowser(); + <% } %> + return bootstrapApplication(AppComponent, appConfig, { platformRef }); }, + NgZone: 'noop', template: '<<%= prefix %>-root />',<% if (routing) { %> Router, NavigationStart,<% } %> - NgZone: 'noop', }); export const bootstrap = lifecycles.bootstrap; diff --git a/schematics/ng-add/_files/src/single-spa/single-spa-props.ts.template b/schematics/ng-add/_files/src/single-spa/single-spa-props.ts.template index 38ffe644..d4c66764 100644 --- a/schematics/ng-add/_files/src/single-spa/single-spa-props.ts.template +++ b/schematics/ng-add/_files/src/single-spa/single-spa-props.ts.template @@ -1,7 +1,7 @@ -import { ReplaySubject } from 'rxjs'; -import { AppProps } from 'single-spa'; +import { signal } from '@angular/core'; +import type { AppProps } from 'single-spa'; -export const singleSpaPropsSubject = new ReplaySubject(1); +export const singleSpaProps = signal(null); // Add any custom single-spa props you have to this type def // https://single-spa.js.org/docs/building-applications.html#custom-props