From 413b670095b350bf786b782a1955599d41934120 Mon Sep 17 00:00:00 2001 From: Karel Frederix Date: Wed, 10 Jun 2026 13:57:21 +0200 Subject: [PATCH 1/2] fix: accept thenable bootstrapFunction return values (#574) Use duck-typed promise detection instead of instanceof Promise so bootstrapFunction works when Angular bundles return a Promise from a different realm or module copy. Co-authored-by: Cursor --- .../src/single-spa-angular.ts | 3 +- .../src/smells-like-a-promise.spec.ts | 29 +++++++++++++++++++ .../src/smells-like-a-promise.ts | 11 +++++++ 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 libs/single-spa-angular/src/smells-like-a-promise.spec.ts create mode 100644 libs/single-spa-angular/src/smells-like-a-promise.ts diff --git a/libs/single-spa-angular/src/single-spa-angular.ts b/libs/single-spa-angular/src/single-spa-angular.ts index c76d652..e4dfa1e 100644 --- a/libs/single-spa-angular/src/single-spa-angular.ts +++ b/libs/single-spa-angular/src/single-spa-angular.ts @@ -3,6 +3,7 @@ import type { LifeCycles } from 'single-spa'; import { getContainerElementAndSetTemplate } from 'single-spa-angular/internals'; import { SingleSpaPlatformLocation } from './platform-providers'; +import { smellsLikeAPromise } from './smells-like-a-promise'; import type { SingleSpaAngularOptions, BootstrappedSingleSpaAngularOptions, @@ -99,7 +100,7 @@ async function mount( const bootstrapPromise = options.bootstrapFunction(props); - if (!(bootstrapPromise instanceof Promise)) { + if (!smellsLikeAPromise(bootstrapPromise)) { throw Error( `single-spa-angular: the options.bootstrapFunction must return a promise, but instead returned a '${typeof bootstrapPromise}' that is not a Promise`, ); diff --git a/libs/single-spa-angular/src/smells-like-a-promise.spec.ts b/libs/single-spa-angular/src/smells-like-a-promise.spec.ts new file mode 100644 index 0000000..c72edb6 --- /dev/null +++ b/libs/single-spa-angular/src/smells-like-a-promise.spec.ts @@ -0,0 +1,29 @@ +import { smellsLikeAPromise } from './smells-like-a-promise'; + +describe('smellsLikeAPromise', () => { + it('returns true for native promises', () => { + expect(smellsLikeAPromise(Promise.resolve())).toBe(true); + }); + + it('returns true for thenables from another realm', () => { + const foreignPromise = { + then: Promise.prototype.then.bind(Promise.resolve()), + catch: Promise.prototype.catch.bind(Promise.resolve()), + }; + + expect(smellsLikeAPromise(foreignPromise)).toBe(true); + }); + + it('returns false for plain objects', () => { + expect(smellsLikeAPromise({})).toBe(false); + }); + + it('returns false for thenables without catch', () => { + expect(smellsLikeAPromise({ then: () => {} })).toBe(false); + }); + + it('returns false for nullish values', () => { + expect(smellsLikeAPromise(null)).toBe(false); + expect(smellsLikeAPromise(undefined)).toBe(false); + }); +}); diff --git a/libs/single-spa-angular/src/smells-like-a-promise.ts b/libs/single-spa-angular/src/smells-like-a-promise.ts new file mode 100644 index 0000000..d8d9320 --- /dev/null +++ b/libs/single-spa-angular/src/smells-like-a-promise.ts @@ -0,0 +1,11 @@ +/** + * Detects thenables across realms and bundled copies of Promise. + * See https://github.com/single-spa/single-spa-angular/issues/574 + */ +export function smellsLikeAPromise(value: unknown): value is PromiseLike { + return ( + !!value && + typeof (value as PromiseLike).then === 'function' && + typeof (value as Promise).catch === 'function' + ); +} From 86ad8597924aeed3c8b35847a1aa79e23507daa2 Mon Sep 17 00:00:00 2001 From: Karel Frederix Date: Thu, 11 Jun 2026 12:51:51 +0200 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20use=20Angular=20=C9=B5isPromise?= =?UTF-8?q?=20for=20bootstrap=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../src/single-spa-angular.ts | 5 ++-- .../src/smells-like-a-promise.spec.ts | 29 ------------------- .../src/smells-like-a-promise.ts | 11 ------- 3 files changed, 2 insertions(+), 43 deletions(-) delete mode 100644 libs/single-spa-angular/src/smells-like-a-promise.spec.ts delete mode 100644 libs/single-spa-angular/src/smells-like-a-promise.ts diff --git a/libs/single-spa-angular/src/single-spa-angular.ts b/libs/single-spa-angular/src/single-spa-angular.ts index e4dfa1e..1b07746 100644 --- a/libs/single-spa-angular/src/single-spa-angular.ts +++ b/libs/single-spa-angular/src/single-spa-angular.ts @@ -1,9 +1,8 @@ -import type { ApplicationRef, NgModuleRef, NgZone } from '@angular/core'; +import { ɵisPromise, type ApplicationRef, type NgModuleRef, type NgZone } from '@angular/core'; import type { LifeCycles } from 'single-spa'; import { getContainerElementAndSetTemplate } from 'single-spa-angular/internals'; import { SingleSpaPlatformLocation } from './platform-providers'; -import { smellsLikeAPromise } from './smells-like-a-promise'; import type { SingleSpaAngularOptions, BootstrappedSingleSpaAngularOptions, @@ -100,7 +99,7 @@ async function mount( const bootstrapPromise = options.bootstrapFunction(props); - if (!smellsLikeAPromise(bootstrapPromise)) { + if (!ɵisPromise(bootstrapPromise)) { throw Error( `single-spa-angular: the options.bootstrapFunction must return a promise, but instead returned a '${typeof bootstrapPromise}' that is not a Promise`, ); diff --git a/libs/single-spa-angular/src/smells-like-a-promise.spec.ts b/libs/single-spa-angular/src/smells-like-a-promise.spec.ts deleted file mode 100644 index c72edb6..0000000 --- a/libs/single-spa-angular/src/smells-like-a-promise.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { smellsLikeAPromise } from './smells-like-a-promise'; - -describe('smellsLikeAPromise', () => { - it('returns true for native promises', () => { - expect(smellsLikeAPromise(Promise.resolve())).toBe(true); - }); - - it('returns true for thenables from another realm', () => { - const foreignPromise = { - then: Promise.prototype.then.bind(Promise.resolve()), - catch: Promise.prototype.catch.bind(Promise.resolve()), - }; - - expect(smellsLikeAPromise(foreignPromise)).toBe(true); - }); - - it('returns false for plain objects', () => { - expect(smellsLikeAPromise({})).toBe(false); - }); - - it('returns false for thenables without catch', () => { - expect(smellsLikeAPromise({ then: () => {} })).toBe(false); - }); - - it('returns false for nullish values', () => { - expect(smellsLikeAPromise(null)).toBe(false); - expect(smellsLikeAPromise(undefined)).toBe(false); - }); -}); diff --git a/libs/single-spa-angular/src/smells-like-a-promise.ts b/libs/single-spa-angular/src/smells-like-a-promise.ts deleted file mode 100644 index d8d9320..0000000 --- a/libs/single-spa-angular/src/smells-like-a-promise.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Detects thenables across realms and bundled copies of Promise. - * See https://github.com/single-spa/single-spa-angular/issues/574 - */ -export function smellsLikeAPromise(value: unknown): value is PromiseLike { - return ( - !!value && - typeof (value as PromiseLike).then === 'function' && - typeof (value as Promise).catch === 'function' - ); -}