From fa9e94e140a0d2dc66d1a6390d2014acec81cb44 Mon Sep 17 00:00:00 2001 From: arturovt Date: Thu, 16 Apr 2026 08:35:27 +0300 Subject: [PATCH] fix: support mounting multiple instances of the same parcel config Previously, the options object returned by `singleSpaAngular()` stored lifecycle state (`bootstrappedRef`, `bootstrappedNgZone`, `routingEventListener`) as flat singleton properties. When the same parcel config was passed to `mountRootParcel()` more than once, all instances shared this state, causing only one parcel to function correctly. Fixes this by introducing a per-instance `instances` map on the options object, keyed by `props.name || props.appName`. Each call to `bootstrap`, `mount`, and `unmount` now reads and writes its own `BootstrappedInstanceRef` entry, so multiple parcels using the same config are fully independent. This mirrors the fix applied to single-spa-react in single-spa/single-spa-react#68 and follows the pattern originally suggested in the issue. Closes #234 --- .../src/single-spa-angular.ts | 40 +++++++++++++------ libs/single-spa-angular/src/types.ts | 4 ++ 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/libs/single-spa-angular/src/single-spa-angular.ts b/libs/single-spa-angular/src/single-spa-angular.ts index cc1eafc..c76d652 100644 --- a/libs/single-spa-angular/src/single-spa-angular.ts +++ b/libs/single-spa-angular/src/single-spa-angular.ts @@ -3,7 +3,11 @@ import type { LifeCycles } from 'single-spa'; import { getContainerElementAndSetTemplate } from 'single-spa-angular/internals'; import { SingleSpaPlatformLocation } from './platform-providers'; -import type { SingleSpaAngularOptions, BootstrappedSingleSpaAngularOptions } from './types'; +import type { + SingleSpaAngularOptions, + BootstrappedSingleSpaAngularOptions, + BootstrappedInstanceRef, +} from './types'; const defaultOptions = { // Required options that will be set by the library consumer. @@ -14,7 +18,7 @@ const defaultOptions = { Router: undefined, domElementGetter: undefined, // only optional if you provide a domElementGetter as a custom prop updateFunction: () => Promise.resolve(), - bootstrappedRef: null, + instances: {}, }; export function singleSpaAngular(userOptions: SingleSpaAngularOptions): LifeCycles { @@ -53,7 +57,12 @@ export function singleSpaAngular(userOptions: SingleSpaAngularOptions): Li }; } -async function bootstrap(options: BootstrappedSingleSpaAngularOptions): Promise { +async function bootstrap(options: BootstrappedSingleSpaAngularOptions, props: any): Promise { + const instance: BootstrappedInstanceRef = { + bootstrappedRef: null, + }; + options.instances[props.name || props.appName] = instance; + if (options.NgZone === 'noop') { return; } @@ -77,8 +86,8 @@ async function bootstrap(options: BootstrappedSingleSpaAngularOptions): Promise< // 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(() => {}); + instance.routingEventListener = () => { + instance.bootstrappedNgZone!.run(() => {}); }; } @@ -126,6 +135,8 @@ async function mount( const bootstrappedOptions = options as BootstrappedSingleSpaAngularOptions; + const instance = bootstrappedOptions.instances[props.name || props.appName]; + if (options.NgZone !== 'noop') { const ngZone: NgZone = bootstrappedRef.injector.get(options.NgZone); @@ -140,22 +151,25 @@ async function mount( skipLocationChangeOnNonImperativeRoutingTriggers(bootstrappedRef, options); } - bootstrappedOptions.bootstrappedNgZone = ngZone; - window.addEventListener('single-spa:routing-event', bootstrappedOptions.routingEventListener!); + instance.bootstrappedNgZone = ngZone; + window.addEventListener('single-spa:routing-event', instance.routingEventListener!); } - bootstrappedOptions.bootstrappedRef = bootstrappedRef; + instance.bootstrappedRef = bootstrappedRef; return bootstrappedRef; } -function unmount(options: BootstrappedSingleSpaAngularOptions): Promise { +function unmount(options: BootstrappedSingleSpaAngularOptions, props: any): Promise { + const instance = options.instances[props.name || props.appName]; + return Promise.resolve().then(() => { - if (options.routingEventListener) { - window.removeEventListener('single-spa:routing-event', options.routingEventListener); + if (instance.routingEventListener) { + window.removeEventListener('single-spa:routing-event', instance.routingEventListener); } - options.bootstrappedRef!.destroy(); - options.bootstrappedRef = null; + instance.bootstrappedRef!.destroy(); + instance.bootstrappedRef = null; + instance.bootstrappedNgZone = undefined; }); } diff --git a/libs/single-spa-angular/src/types.ts b/libs/single-spa-angular/src/types.ts index aeec12e..e1fdbc0 100644 --- a/libs/single-spa-angular/src/types.ts +++ b/libs/single-spa-angular/src/types.ts @@ -16,6 +16,10 @@ export interface SingleSpaAngularOptions< } export interface BootstrappedSingleSpaAngularOptions extends SingleSpaAngularOptions { + instances: Record; +} + +export interface BootstrappedInstanceRef { bootstrappedRef: NgModuleRef | ApplicationRef | null; // All below properties can be optional in case of // `SingleSpaAngularOpts.NgZone` is a `noop` string and not an `NgZone` class.