From 44601f040281727212f3e41ed0b435a2e22720a7 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 17 Jun 2026 13:09:48 -0300 Subject: [PATCH 1/5] feat: throw error on failed to open dialog and refactor some deprecated usages --- .../src/lib/cdk/dialog/native-modal-ref.ts | 43 ++++++++++++------- .../src/lib/cdk/portal/nsdom-portal-outlet.ts | 3 +- .../angular/src/lib/detached-loader-utils.ts | 2 +- .../src/lib/legacy/directives/dialogs.ts | 27 ++++++++++-- packages/angular/src/lib/utils/general.ts | 28 ++++++++++++ 5 files changed, 81 insertions(+), 22 deletions(-) diff --git a/packages/angular/src/lib/cdk/dialog/native-modal-ref.ts b/packages/angular/src/lib/cdk/dialog/native-modal-ref.ts index 62005baa..e2a8f6de 100644 --- a/packages/angular/src/lib/cdk/dialog/native-modal-ref.ts +++ b/packages/angular/src/lib/cdk/dialog/native-modal-ref.ts @@ -1,10 +1,10 @@ -import { ApplicationRef, ComponentFactoryResolver, ComponentRef, createComponent, EmbeddedViewRef, Injector, Optional, ViewContainerRef } from '@angular/core'; +import { ApplicationRef, ComponentRef, createComponent, EmbeddedViewRef, Injector, Optional, ViewContainerRef } from '@angular/core'; import { Application, ContentView, Frame, View } from '@nativescript/core'; import { fromEvent, Subject } from 'rxjs'; import { take } from 'rxjs/operators'; import { AppHostAsyncView, AppHostView } from '../../app-host-view'; import { NSLocationStrategy } from '../../legacy/router/ns-location-strategy'; -import { once } from '../../utils/general'; +import { didModalOpen, once } from '../../utils/general'; import { NgViewRef } from '../../view-refs'; import { DetachedLoader } from '../detached-loader'; import { ComponentPortal, TemplatePortal } from '../portal/common'; @@ -86,12 +86,7 @@ export class NativeModalRef { this._generateDetachedContainer(vcRef); portal.viewContainerRef = this.detachedLoaderRef.instance.vc; const targetView = new ContentView(); - this.portalOutlet = new NativeScriptDomPortalOutlet( - targetView, - this._config.componentFactoryResolver || this._injector.get(ComponentFactoryResolver), - this._injector.get(ApplicationRef), - this._injector, - ); + this.portalOutlet = new NativeScriptDomPortalOutlet(targetView, this._injector.get(ApplicationRef), this._injector); const templateRef = this.portalOutlet.attach(portal); this.modalViewRef = new NgViewRef(templateRef); this.modalViewRef.firstNativeLikeView['__ng_modal_id__'] = this._id; @@ -99,7 +94,8 @@ export class NativeModalRef { this.modalViewRef.detachNativeLikeView(); const userOptions = this._config.nativeOptions || {}; - this.parentView.showModal(this.modalViewRef.firstNativeLikeView, { + const modalView = this.modalViewRef.firstNativeLikeView; + this.parentView.showModal(modalView, { context: null, ...userOptions, closeCallback: async () => { @@ -109,6 +105,9 @@ export class NativeModalRef { }, cancelable: !this._config.disableClose, }); + if (!didModalOpen(this.parentView, modalView)) { + this._handleFailedOpen(); + } // if (this.modalView !== templateRef.rootNodes[0]) { // componentRef.location.nativeElement._ngDialogRoot = this.modalView; // } @@ -119,12 +118,7 @@ export class NativeModalRef { this.startModalNavigation(); const targetView = new ContentView(); - this.portalOutlet = new NativeScriptDomPortalOutlet( - targetView, - this._config.componentFactoryResolver || this._injector.get(ComponentFactoryResolver), - this._injector.get(ApplicationRef), - this._injector, - ); + this.portalOutlet = new NativeScriptDomPortalOutlet(targetView, this._injector.get(ApplicationRef), this._injector); const componentRef = this.portalOutlet.attach(portal); componentRef.changeDetectorRef.detectChanges(); this.modalViewRef = new NgViewRef(componentRef); @@ -136,7 +130,8 @@ export class NativeModalRef { this.modalViewRef.detachNativeLikeView(); const userOptions = this._config.nativeOptions || {}; - this.parentView.showModal(this.modalViewRef.firstNativeLikeView, { + const modalView = this.modalViewRef.firstNativeLikeView; + this.parentView.showModal(modalView, { context: null, ...userOptions, closeCallback: async () => { @@ -147,6 +142,9 @@ export class NativeModalRef { }, cancelable: !this._config.disableClose, }); + if (!didModalOpen(this.parentView, modalView)) { + this._handleFailedOpen(); + } return componentRef; } @@ -154,6 +152,19 @@ export class NativeModalRef { this._closeCallback(); } + /** + * Rolls back everything that was set up to present the modal when NativeScript silently + * failed to actually present it. Without this the modal navigation stack stays incremented + * (blocking further navigation) and the attached view/loader leak on the `ApplicationRef`. + */ + private _handleFailedOpen(): never { + this._isDismissed = true; + this.location?._closeModalNavigation(); + this.portalOutlet?.dispose(); + this.detachedLoaderRef?.destroy(); + throw new Error('Failed to open dialog: the modal view could not be presented. This usually happens when another modal is already being presented.'); + } + dispose() { this.portalOutlet.dispose(); } diff --git a/packages/angular/src/lib/cdk/portal/nsdom-portal-outlet.ts b/packages/angular/src/lib/cdk/portal/nsdom-portal-outlet.ts index e629710c..8cb1678e 100644 --- a/packages/angular/src/lib/cdk/portal/nsdom-portal-outlet.ts +++ b/packages/angular/src/lib/cdk/portal/nsdom-portal-outlet.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import { ComponentFactoryResolver, ComponentRef, EmbeddedViewRef, ApplicationRef, Injector, Renderer2, Optional, createComponent } from '@angular/core'; +import { ComponentRef, EmbeddedViewRef, ApplicationRef, Injector, Renderer2, Optional, createComponent } from '@angular/core'; import { View } from '@nativescript/core'; import { CommentNode } from '../../views/invisible-nodes'; import { ViewUtil } from '../../view-util'; @@ -21,7 +21,6 @@ export class NativeScriptDomPortalOutlet extends BasePortalOutlet { constructor( /** Element into which the content is projected. */ public outletElement: View, - private _componentFactoryResolver: ComponentFactoryResolver, private _appRef: ApplicationRef, private _defaultInjector: Injector, @Optional() viewUtil?: ViewUtil, diff --git a/packages/angular/src/lib/detached-loader-utils.ts b/packages/angular/src/lib/detached-loader-utils.ts index a1a24199..d6bb4cd9 100644 --- a/packages/angular/src/lib/detached-loader-utils.ts +++ b/packages/angular/src/lib/detached-loader-utils.ts @@ -73,7 +73,7 @@ export function generateNativeScriptView( portal = new ComponentPortal(typeOrTemplate, detachedLoaderRef?.instance.vc); } const parentView = new ContentView(); - const portalOutlet = new NativeScriptDomPortalOutlet(parentView, resolver, injector.get(ApplicationRef), injector); + const portalOutlet = new NativeScriptDomPortalOutlet(parentView, injector.get(ApplicationRef), injector); const componentOrTemplateRef: ComponentRef | EmbeddedViewRef = portalOutlet.attach(portal); if (detachedLoaderRef && !reusingDetachedLoader) { componentOrTemplateRef.onDestroy(() => { diff --git a/packages/angular/src/lib/legacy/directives/dialogs.ts b/packages/angular/src/lib/legacy/directives/dialogs.ts index 3ec523b2..614777e4 100644 --- a/packages/angular/src/lib/legacy/directives/dialogs.ts +++ b/packages/angular/src/lib/legacy/directives/dialogs.ts @@ -5,7 +5,7 @@ import { AppHostAsyncView, AppHostView } from '../../app-host-view'; import { DetachedLoader } from '../../cdk/detached-loader'; import { ComponentPortal } from '../../cdk/portal/common'; import { NativeScriptDomPortalOutlet } from '../../cdk/portal/nsdom-portal-outlet'; -import { once } from '../../utils/general'; +import { didModalOpen, once } from '../../utils/general'; import { NgViewRef } from '../../view-refs'; import { NSLocationStrategy } from '../router/ns-location-strategy'; @@ -167,7 +167,7 @@ export class ModalDialogService { // } const targetView = new ContentView(); const portal = new ComponentPortal(options.type); - portalOutlet = new NativeScriptDomPortalOutlet(targetView, options.resolver, this.appRef, childInjector); + portalOutlet = new NativeScriptDomPortalOutlet(targetView, this.appRef, childInjector); const componentRef = portalOutlet.attach(portal); componentRef.changeDetectorRef.detectChanges(); componentViewRef = new NgViewRef(componentRef); @@ -181,7 +181,28 @@ export class ModalDialogService { } // if we don't detach the view from its parent, ios gets mad componentViewRef.detachNativeLikeView(); - options.parentView.showModal(componentViewRef.firstNativeLikeView, { ...options, closeCallback }); + const modalView = componentViewRef.firstNativeLikeView; + options.parentView.showModal(modalView, { ...options, closeCallback }); + if (!didModalOpen(options.parentView as View, modalView)) { + this._handleFailedOpen(modalParams, portalOutlet, detachedLoaderRef); + } }); } + + /** + * Rolls back everything that was set up to present the modal when NativeScript silently + * failed to actually present it. Without this the modal navigation stack stays incremented + * (blocking further navigation) and the attached view/loader leak on the `ApplicationRef`. + */ + private _handleFailedOpen(modalParams: ModalDialogParams, portalOutlet?: NativeScriptDomPortalOutlet, detachedLoaderRef?: ComponentRef): never { + const index = this.openedModalParams?.indexOf(modalParams) ?? -1; + if (index > -1) { + this.openedModalParams.splice(index, 1); + } + this.location?._closeModalNavigation(); + portalOutlet?.dispose(); + detachedLoaderRef?.instance.detectChanges(); + detachedLoaderRef?.destroy(); + throw new Error('Failed to open dialog: the modal view could not be presented. This usually happens when another modal is already being presented.'); + } } diff --git a/packages/angular/src/lib/utils/general.ts b/packages/angular/src/lib/utils/general.ts index b59a6d46..2d1fa01e 100644 --- a/packages/angular/src/lib/utils/general.ts +++ b/packages/angular/src/lib/utils/general.ts @@ -1,3 +1,31 @@ +import { Application, View } from '@nativescript/core'; + +/** + * NativeScript can silently fail to present a modal view (for example, on iOS when the + * parent is already presenting another view controller, or the parent isn't part of the + * window hierarchy). In those cases `showModal()` returns without actually presenting and + * without raising an error, which would otherwise leave modal navigation stuck and the + * attached Angular view leaked on the `ApplicationRef`. This checks whether the modal was + * really presented so callers can roll everything back when it wasn't. + * + * @param parentView The view `showModal()` was called on. + * @param modalView The view that was passed to `showModal()`. + */ +export function didModalOpen(parentView: View, modalView: View): boolean { + // On a successful present, core synchronously sets the parent's `modal` to the modal view. + if (parentView && parentView.modal === modalView) { + return true; + } + + // On Android, presenting while the app is backgrounded and the parent isn't loaded is + // deferred until the parent loads again rather than failing, so treat it as opened. + if (global.isAndroid && Application.inBackground && parentView && !parentView.isLoaded) { + return true; + } + + return false; +} + /** * Utility method to ensure a NgModule is only imported once in a codebase, otherwise will throw to help prevent accidental double importing * @param parentModule Parent module name From 611e4fe446cc5f9e22963646762b7bf026670f27 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 17 Jun 2026 13:25:49 -0300 Subject: [PATCH 2/5] feat: update cdk-inspired components and drop ComponentFactoryResolver --- .../src/tests/detached-utils-tests.spec.ts | 9 +--- .../angular/src/lib/cdk/detached-loader.ts | 9 ++-- .../src/lib/cdk/dialog/dialog-config.ts | 9 ++-- .../src/lib/cdk/dialog/dialog-services.ts | 14 ++--- packages/angular/src/lib/cdk/portal/common.ts | 48 ++++++++++++----- .../src/lib/cdk/portal/nsdom-portal-outlet.ts | 36 ++++++++++--- .../src/lib/cdk/portal/portal-directives.ts | 53 +++++++++---------- .../angular/src/lib/detached-loader-utils.ts | 25 +++++---- .../src/lib/legacy/directives/dialogs.ts | 17 +----- .../lib/legacy/router/page-router-outlet.ts | 36 +++---------- 10 files changed, 126 insertions(+), 130 deletions(-) diff --git a/apps/nativescript-demo-ng/src/tests/detached-utils-tests.spec.ts b/apps/nativescript-demo-ng/src/tests/detached-utils-tests.spec.ts index 952c8fbc..6297d929 100644 --- a/apps/nativescript-demo-ng/src/tests/detached-utils-tests.spec.ts +++ b/apps/nativescript-demo-ng/src/tests/detached-utils-tests.spec.ts @@ -1,7 +1,5 @@ import { Component, - ComponentFactory, - ComponentFactoryResolver, ComponentRef, EmbeddedViewRef, inject, @@ -10,7 +8,7 @@ import { NO_ERRORS_SCHEMA, TemplateRef, ViewChild, - ViewContainerRef, + ViewContainerRef } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { generateDetachedLoader, generateNativeScriptView, NativeScriptCommonModule, NgViewRef } from '@nativescript/angular'; @@ -82,10 +80,7 @@ describe('generateNativeScriptView', () => { }); it('should reuse a DetachedLoaderRef', () => { - const containerRef = generateDetachedLoader( - fixture.componentRef.instance.injector.get(ComponentFactoryResolver), - fixture.componentRef.instance.injector, - ); + const containerRef = generateDetachedLoader(fixture.componentRef.instance.injector); cleanup.push(containerRef); const ngViewRef = generateNativeScriptView(GeneratedComponent, { injector: fixture.componentRef.instance.injector, diff --git a/packages/angular/src/lib/cdk/detached-loader.ts b/packages/angular/src/lib/cdk/detached-loader.ts index 51d26258..8488e36d 100644 --- a/packages/angular/src/lib/cdk/detached-loader.ts +++ b/packages/angular/src/lib/cdk/detached-loader.ts @@ -1,4 +1,4 @@ -import { ApplicationRef, ChangeDetectorRef, Component, ComponentFactory, ComponentFactoryResolver, ComponentRef, inject, Injector, NO_ERRORS_SCHEMA, OnDestroy, TemplateRef, Type, ViewChild, ViewContainerRef } from '@angular/core'; +import { ApplicationRef, ChangeDetectorRef, Component, ComponentFactory, ComponentRef, createComponent, inject, Injector, NO_ERRORS_SCHEMA, OnDestroy, TemplateRef, Type, ViewChild, ViewContainerRef } from '@angular/core'; import { ProxyViewContainer, Trace } from '@nativescript/core'; import { registerElement } from '../element-registry'; import type { ComponentType } from '../utils/general'; @@ -27,7 +27,6 @@ export class DetachedLoader implements OnDestroy { @ViewChild('vc', { read: ViewContainerRef, static: true }) vc: ViewContainerRef; private disposeFunctions: Array<() => void> = []; // tslint:disable-line:component-class-suffix - resolver = inject(ComponentFactoryResolver); changeDetector = inject(ChangeDetectorRef); containerRef = inject(ViewContainerRef); appRef = inject(ApplicationRef); @@ -41,8 +40,10 @@ export class DetachedLoader implements OnDestroy { } private loadInAppRef(componentType: Type): ComponentRef { - const factory = this.resolver.resolveComponentFactory(componentType); - const componentRef = factory.create(this.containerRef.injector); + const componentRef = createComponent(componentType, { + environmentInjector: this.appRef.injector, + elementInjector: this.containerRef.injector, + }); this.appRef.attachView(componentRef.hostView); this.disposeFunctions.push(() => { diff --git a/packages/angular/src/lib/cdk/dialog/dialog-config.ts b/packages/angular/src/lib/cdk/dialog/dialog-config.ts index 38468c22..ab47be0c 100644 --- a/packages/angular/src/lib/cdk/dialog/dialog-config.ts +++ b/packages/angular/src/lib/cdk/dialog/dialog-config.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import { ViewContainerRef, ComponentFactoryResolver, Injector } from '@angular/core'; +import { ViewContainerRef, Binding, Injector } from '@angular/core'; import { ShowModalOptions, View } from '@nativescript/core'; export type NativeShowModalOptions = Partial>; @@ -50,10 +50,11 @@ export class NativeDialogConfig { */ closeOnNavigation?: boolean = true; - /** Alternate `ComponentFactoryResolver` to use when resolving the associated component. - * @deprecated + /** + * Bindings to apply to the component rendered inside the dialog. + * Does nothing for template-based dialogs. */ - componentFactoryResolver?: ComponentFactoryResolver; + bindings?: Binding[]; nativeOptions?: NativeShowModalOptions = {}; diff --git a/packages/angular/src/lib/cdk/dialog/dialog-services.ts b/packages/angular/src/lib/cdk/dialog/dialog-services.ts index 37f89b50..13417ccf 100644 --- a/packages/angular/src/lib/cdk/dialog/dialog-services.ts +++ b/packages/angular/src/lib/cdk/dialog/dialog-services.ts @@ -160,23 +160,15 @@ export class NativeDialog implements OnDestroy { const dialogRef = new this._dialogRefConstructor(nativeModalRef, config.id); if (componentOrTemplateRef instanceof TemplateRef) { - // const detachedFactory = options.resolver.resolveComponentFactory(DetachedLoader); - // if(options.attachToContainerRef) { - // detachedLoaderRef = options.attachToContainerRef.createComponent(detachedFactory, 0, childInjector, null); - // } else { - // detachedLoaderRef = detachedFactory.create(childInjector); // this DetachedLoader is **completely** detached - // this.appRef.attachView(detachedLoaderRef.hostView); // we attach it to the applicationRef, so it becomes a "root" view in angular's hierarchy - // } - // detachedLoaderRef.changeDetectorRef.detectChanges(); // force a change detection - // detachedLoaderRef.instance.createTemplatePortal(options.templateRef); + const injector = this._createInjector(config, dialogRef); nativeModalRef.attachTemplatePortal( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - new TemplatePortal(componentOrTemplateRef, null!, { $implicit: config.data, dialogRef }), + new TemplatePortal(componentOrTemplateRef, null!, { $implicit: config.data, dialogRef }, injector), ); } else { const injector = this._createInjector(config, dialogRef); const contentRef = nativeModalRef.attachComponentPortal( - new ComponentPortal(componentOrTemplateRef, config.viewContainerRef, injector), + new ComponentPortal(componentOrTemplateRef, config.viewContainerRef, injector, null, config.bindings), ); dialogRef.componentInstance = contentRef.instance; } diff --git a/packages/angular/src/lib/cdk/portal/common.ts b/packages/angular/src/lib/cdk/portal/common.ts index f52896c7..aa9e9689 100644 --- a/packages/angular/src/lib/cdk/portal/common.ts +++ b/packages/angular/src/lib/cdk/portal/common.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import { TemplateRef, ViewContainerRef, ElementRef, ComponentRef, EmbeddedViewRef, Injector, ComponentFactoryResolver } from '@angular/core'; +import { TemplateRef, ViewContainerRef, ElementRef, ComponentRef, EmbeddedViewRef, Injector, Binding, Type, DirectiveWithBindings } from '@angular/core'; import { View } from '@nativescript/core'; import { throwNullPortalOutletError, throwPortalAlreadyAttachedError, throwNoPortalAttachedError, throwNullPortalError, throwPortalOutletAlreadyDisposedError, throwUnknownPortalTypeError } from './portal-errors'; import type { ComponentType } from '../../utils/general'; @@ -16,7 +16,7 @@ import type { ComponentType } from '../../utils/general'; * It can be attach to / detached from a `PortalOutlet`. */ export abstract class Portal { - private _attachedHost: PortalOutlet | null; + private _attachedHost: PortalOutlet | null = null; /** Attach this portal to a host. */ attach(host: PortalOutlet): T { @@ -68,27 +68,45 @@ export class ComponentPortal extends Portal> { component: ComponentType; /** - * [Optional] Where the attached component should live in Angular's *logical* component tree. + * Where the attached component should live in Angular's *logical* component tree. * This is different from where the component *renders*, which is determined by the PortalOutlet. * The origin is necessary when the host is outside of the Angular application context. */ viewContainerRef?: ViewContainerRef | null; - /** [Optional] Injector used for the instantiation of the component. */ + /** Injector used for the instantiation of the component. */ injector?: Injector | null; /** - * Alternate `ComponentFactoryResolver` to use when resolving the associated component. - * Defaults to using the resolver from the outlet that the portal is attached to. + * List of NativeScript views that should be projected through `` of the attached component. */ - componentFactoryResolver?: ComponentFactoryResolver | null; + projectableNodes?: View[][] | null; - constructor(component: ComponentType, viewContainerRef?: ViewContainerRef | null, injector?: Injector | null, componentFactoryResolver?: ComponentFactoryResolver | null) { + /** + * Bindings to apply to the created component. + */ + readonly bindings: Binding[] | null; + + /** + * Directives to apply to the created component. + */ + readonly directives: (Type | DirectiveWithBindings)[] | null; + + constructor( + component: ComponentType, + viewContainerRef?: ViewContainerRef | null, + injector?: Injector | null, + projectableNodes?: View[][] | null, + bindings?: Binding[], + directives?: (Type | DirectiveWithBindings)[], + ) { super(); this.component = component; this.viewContainerRef = viewContainerRef; this.injector = injector; - this.componentFactoryResolver = componentFactoryResolver; + this.projectableNodes = projectableNodes; + this.bindings = bindings || null; + this.directives = directives || null; } } @@ -105,11 +123,15 @@ export class TemplatePortal extends Portal> { /** Contextual data to be passed in to the embedded view. */ context: C | undefined; - constructor(template: TemplateRef, viewContainerRef: ViewContainerRef, context?: C) { + /** The injector to use for the embedded view. */ + injector: Injector | undefined; + + constructor(template: TemplateRef, viewContainerRef: ViewContainerRef, context?: C, injector?: Injector) { super(); this.templateRef = template; this.viewContainerRef = viewContainerRef; this.context = context; + this.injector = injector; } get origin(): ElementRef { @@ -168,13 +190,13 @@ export interface PortalOutlet { */ export abstract class BasePortalOutlet implements PortalOutlet { /** The portal currently attached to the host. */ - protected _attachedPortal: Portal | null; + protected _attachedPortal: Portal | null = null; /** A function that will permanently dispose this host. */ - private _disposeFn: (() => void) | null; + private _disposeFn: (() => void) | null = null; /** Whether this host has already been permanently disposed. */ - private _isDisposed = false; + private _isDisposed: boolean = false; /** Whether this host has an attached portal. */ hasAttached(): boolean { diff --git a/packages/angular/src/lib/cdk/portal/nsdom-portal-outlet.ts b/packages/angular/src/lib/cdk/portal/nsdom-portal-outlet.ts index 8cb1678e..5d472e73 100644 --- a/packages/angular/src/lib/cdk/portal/nsdom-portal-outlet.ts +++ b/packages/angular/src/lib/cdk/portal/nsdom-portal-outlet.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import { ComponentRef, EmbeddedViewRef, ApplicationRef, Injector, Renderer2, Optional, createComponent } from '@angular/core'; +import { ComponentRef, EmbeddedViewRef, ApplicationRef, Injector, EnvironmentInjector, NgModuleRef, Optional, createComponent } from '@angular/core'; import { View } from '@nativescript/core'; import { CommentNode } from '../../views/invisible-nodes'; import { ViewUtil } from '../../view-util'; @@ -30,7 +30,7 @@ export class NativeScriptDomPortalOutlet extends BasePortalOutlet { } /** - * Attach the given ComponentPortal to DOM element using the ComponentFactoryResolver. + * Attach the given ComponentPortal to DOM element. * @param portal Portal to be attached * @returns Reference to the created component. */ @@ -42,20 +42,36 @@ export class NativeScriptDomPortalOutlet extends BasePortalOutlet { // When the ViewContainerRef is missing, we use the factory to create the component directly // and then manually attach the view to the application. if (portal.viewContainerRef) { + const injector = portal.injector || portal.viewContainerRef.injector; + const ngModuleRef = injector.get(NgModuleRef, null, { optional: true }) || undefined; + componentRef = portal.viewContainerRef.createComponent(portal.component, { index: portal.viewContainerRef.length, - injector: portal.injector || portal.viewContainerRef.injector, + injector, + ngModuleRef, + projectableNodes: (portal.projectableNodes as unknown as Node[][]) || undefined, + bindings: portal.bindings || undefined, + directives: portal.directives || undefined, }); this.setDisposeFn(() => componentRef.destroy()); } else { + const elementInjector = portal.injector || this._defaultInjector || Injector.NULL; + const environmentInjector = elementInjector.get(EnvironmentInjector, this._appRef.injector); componentRef = createComponent(portal.component, { - elementInjector: portal.injector || this._defaultInjector || Injector.NULL, - environmentInjector: this._appRef.injector, + elementInjector, + environmentInjector, + projectableNodes: (portal.projectableNodes as unknown as Node[][]) || undefined, + bindings: portal.bindings || undefined, + directives: portal.directives || undefined, }); this._appRef.attachView(componentRef.hostView); this.setDisposeFn(() => { - this._appRef.detachView(componentRef.hostView); + // Verify that the ApplicationRef has registered views before trying to detach a host view. + // This check also protects the `detachView` from being called on a destroyed ApplicationRef. + if (this._appRef.viewCount > 0) { + this._appRef.detachView(componentRef.hostView); + } componentRef.destroy(); }); } @@ -66,6 +82,7 @@ export class NativeScriptDomPortalOutlet extends BasePortalOutlet { this._viewUtil.removeChild(rootNode.parent as View, rootNode); } this._viewUtil.appendChild(this.outletElement, this._getComponentRootNode(componentRef)); + this._attachedPortal = portal; return componentRef; } @@ -77,7 +94,9 @@ export class NativeScriptDomPortalOutlet extends BasePortalOutlet { */ attachTemplatePortal(portal: TemplatePortal): EmbeddedViewRef { const viewContainer = portal.viewContainerRef; - const viewRef = viewContainer.createEmbeddedView(portal.templateRef, portal.context); + const viewRef = viewContainer.createEmbeddedView(portal.templateRef, portal.context, { + injector: portal.injector, + }); // The method `createEmbeddedView` will add the view as a child of the viewContainer. // But for the DomPortalOutlet the view can be added everywhere in the DOM @@ -102,6 +121,8 @@ export class NativeScriptDomPortalOutlet extends BasePortalOutlet { } }); + this._attachedPortal = portal; + // TODO(jelbourn): Return locals from view. return viewRef; } @@ -124,6 +145,7 @@ export class NativeScriptDomPortalOutlet extends BasePortalOutlet { this._viewUtil.insertBefore(element.parentNode as View, anchorNode, element); this._viewUtil.appendChild(this.outletElement, element); + this._attachedPortal = portal; super.setDisposeFn(() => { // We can't use `replaceWith` here because IE doesn't support it. diff --git a/packages/angular/src/lib/cdk/portal/portal-directives.ts b/packages/angular/src/lib/cdk/portal/portal-directives.ts index 6d8aa93c..e90a123b 100644 --- a/packages/angular/src/lib/cdk/portal/portal-directives.ts +++ b/packages/angular/src/lib/cdk/portal/portal-directives.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import { ComponentRef, Directive, EmbeddedViewRef, EventEmitter, NgModule, OnDestroy, OnInit, Output, Renderer2, TemplateRef, ViewContainerRef } from '@angular/core'; +import { ComponentRef, Directive, EmbeddedViewRef, EventEmitter, Input, NgModule, NgModuleRef, OnDestroy, OnInit, Output, Renderer2, TemplateRef, ViewContainerRef, inject } from '@angular/core'; import { View } from '@nativescript/core'; import { CommentNode } from '../../views/invisible-nodes'; import { BasePortalOutlet, ComponentPortal, DomPortal, Portal, TemplatePortal } from './common'; @@ -18,10 +18,12 @@ import { BasePortalOutlet, ComponentPortal, DomPortal, Portal, TemplatePortal } @Directive({ selector: '[cdkPortal]', exportAs: 'cdkPortal', - standalone: true, }) export class CdkPortal extends TemplatePortal { - constructor(templateRef: TemplateRef, viewContainerRef: ViewContainerRef) { + constructor() { + const templateRef = inject>(TemplateRef); + const viewContainerRef = inject(ViewContainerRef); + super(templateRef, viewContainerRef); } } @@ -41,30 +43,25 @@ export type CdkPortalOutletAttachedRef = ComponentRef | EmbeddedViewRef | null { return this._attachedPortal; } - set portal(portal: Portal | null) { + set portal(portal: Portal | null | undefined | '') { // Ignore the cases where the `portal` is set to a falsy value before the lifecycle hooks have // run. This handles the cases where the user might do something like `
` // and attach a portal programmatically in the parent component. When Angular does the first CD @@ -81,7 +78,7 @@ export class CdkPortalOutlet extends BasePortalOutlet implements OnInit, OnDestr super.attach(portal); } - this._attachedPortal = portal; + this._attachedPortal = portal || null; } /** Emits when a portal is attached to the outlet. */ @@ -98,12 +95,11 @@ export class CdkPortalOutlet extends BasePortalOutlet implements OnInit, OnDestr ngOnDestroy() { super.dispose(); - this._attachedPortal = null; - this._attachedRef = null; + this._attachedRef = this._attachedPortal = null; } /** - * Attach the given ComponentPortal to this PortalOutlet using the ComponentFactoryResolver. + * Attach the given ComponentPortal to this PortalOutlet. * * @param portal Portal to be attached to the portal outlet. * @returns Reference to the created component. @@ -115,10 +111,14 @@ export class CdkPortalOutlet extends BasePortalOutlet implements OnInit, OnDestr // in the application tree. Otherwise use the location of this PortalOutlet. const viewContainerRef = portal.viewContainerRef != null ? portal.viewContainerRef : this._viewContainerRef; - // const resolver = portal.componentFactoryResolver || this._componentFactoryResolver; - // const componentFactory = resolver.resolveComponentFactory(portal.component); - const ref = viewContainerRef.createComponent(portal.component); - // const ref = viewContainerRef.createComponent(portal.component, viewContainerRef.length, portal.injector || viewContainerRef.injector); + const ref = viewContainerRef.createComponent(portal.component, { + index: viewContainerRef.length, + injector: portal.injector || viewContainerRef.injector, + projectableNodes: (portal.projectableNodes as unknown as Node[][]) || undefined, + ngModuleRef: this._moduleRef || undefined, + bindings: portal.bindings || undefined, + directives: portal.directives || undefined, + }); // If we're using a view container that's different from the injected one (e.g. when the portal // specifies its own) we need to move the component into the outlet, otherwise it'll be rendered @@ -142,7 +142,9 @@ export class CdkPortalOutlet extends BasePortalOutlet implements OnInit, OnDestr */ attachTemplatePortal(portal: TemplatePortal): EmbeddedViewRef { portal.setAttachedHost(this); - const viewRef = this._viewContainerRef.createEmbeddedView(portal.templateRef, portal.context); + const viewRef = this._viewContainerRef.createEmbeddedView(portal.templateRef, portal.context, { + injector: portal.injector, + }); super.setDisposeFn(() => this._viewContainerRef.clear()); this._attachedPortal = portal; @@ -190,9 +192,6 @@ export class CdkPortalOutlet extends BasePortalOutlet implements OnInit, OnDestr // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return (!(nativeElement instanceof CommentNode) ? nativeElement : nativeElement.parentNode!) as View; } - - // eslint-disable-next-line @typescript-eslint/member-ordering - static ngAcceptInputType_portal: Portal | null | undefined | ''; } @NgModule({ diff --git a/packages/angular/src/lib/detached-loader-utils.ts b/packages/angular/src/lib/detached-loader-utils.ts index d6bb4cd9..0a8c91b5 100644 --- a/packages/angular/src/lib/detached-loader-utils.ts +++ b/packages/angular/src/lib/detached-loader-utils.ts @@ -1,8 +1,9 @@ import { ApplicationRef, - ComponentFactoryResolver, ComponentRef, + createComponent, EmbeddedViewRef, + EnvironmentInjector, Injector, TemplateRef, Type, @@ -15,20 +16,20 @@ import { NgViewRef } from './view-refs'; /** * creates a DetachedLoader either linked to the ViewContainerRef or the ApplicationRef if ViewContainerRef is not defined - * @param resolver component factory resolver * @param injector default injector, unused if viewContainerRef is set * @param viewContainerRef where the view should live in the angular tree * @returns reference to the DetachedLoader */ -export function generateDetachedLoader( - resolver: ComponentFactoryResolver, - injector: Injector, - viewContainerRef?: ViewContainerRef, -) { +export function generateDetachedLoader(injector: Injector, viewContainerRef?: ViewContainerRef) { injector = viewContainerRef?.injector || injector; - const detachedFactory = resolver.resolveComponentFactory(DetachedLoader); - const detachedLoaderRef = viewContainerRef?.createComponent(detachedFactory) || detachedFactory.create(injector); - if (!viewContainerRef) { + let detachedLoaderRef: ComponentRef; + if (viewContainerRef) { + detachedLoaderRef = viewContainerRef.createComponent(DetachedLoader); + } else { + detachedLoaderRef = createComponent(DetachedLoader, { + environmentInjector: injector.get(EnvironmentInjector), + elementInjector: injector, + }); injector.get(ApplicationRef).attachView(detachedLoaderRef.hostView); } detachedLoaderRef.changeDetectorRef.detectChanges(); @@ -46,7 +47,6 @@ export function generateDetachedLoader( export function generateNativeScriptView( typeOrTemplate: Type | TemplateRef, options: { - resolver?: ComponentFactoryResolver; viewContainerRef?: ViewContainerRef; injector: Injector; keepNativeViewAttached?: boolean; @@ -62,9 +62,8 @@ export function generateNativeScriptView( options.viewContainerRef = detachedLoaderRef.instance.vc; } const injector = options.viewContainerRef?.injector || options.injector; - const resolver = options.resolver || injector.get(ComponentFactoryResolver); if (!detachedLoaderRef && (options.viewContainerRef || typeOrTemplate instanceof TemplateRef)) { - detachedLoaderRef = generateDetachedLoader(resolver, injector, options.viewContainerRef); + detachedLoaderRef = generateDetachedLoader(injector, options.viewContainerRef); } let portal: ComponentPortal | TemplatePortal; if (typeOrTemplate instanceof TemplateRef) { diff --git a/packages/angular/src/lib/legacy/directives/dialogs.ts b/packages/angular/src/lib/legacy/directives/dialogs.ts index 614777e4..82252bcb 100644 --- a/packages/angular/src/lib/legacy/directives/dialogs.ts +++ b/packages/angular/src/lib/legacy/directives/dialogs.ts @@ -1,4 +1,4 @@ -import { ApplicationRef, ComponentFactoryResolver, ComponentRef, Injectable, Injector, NgModuleRef, NgZone, Type, ViewContainerRef } from '@angular/core'; +import { ApplicationRef, ComponentRef, Injectable, Injector, NgModuleRef, NgZone, Type, ViewContainerRef } from '@angular/core'; import { Application, ContentView, Frame, ShowModalOptions, View, ViewBase } from '@nativescript/core'; import { Subject } from 'rxjs'; import { AppHostAsyncView, AppHostView } from '../../app-host-view'; @@ -34,7 +34,6 @@ export interface ShowDialogOptions extends ModalDialogOptions { doneCallback; pageFactory?: any; parentView: ViewBase; - resolver: ComponentFactoryResolver; type: Type; } @@ -89,7 +88,6 @@ export class ModalDialogService { // resolve from particular module (moduleRef) // or from same module as parentView (viewContainerRef) const componentInjector = options.moduleRef?.injector || options.viewContainerRef?.injector || this.defaultInjector; - const resolver = componentInjector.get(ComponentFactoryResolver); let frame = parentView; if (!(parentView instanceof Frame)) { @@ -108,7 +106,6 @@ export class ModalDialogService { context: options.context, doneCallback: resolve, parentView, - resolver, type, }); } catch (err) { @@ -153,18 +150,6 @@ export class ModalDialogService { parent: options.injector, }); this.zone.run(() => { - // if we ever support templates in the old API - // if(options.templateRef) { - // const detachedFactory = options.resolver.resolveComponentFactory(DetachedLoader); - // if(options.attachToContainerRef) { - // detachedLoaderRef = options.attachToContainerRef.createComponent(detachedFactory, 0, childInjector, null); - // } else { - // detachedLoaderRef = detachedFactory.create(childInjector); // this DetachedLoader is **completely** detached - // this.appRef.attachView(detachedLoaderRef.hostView); // we attach it to the applicationRef, so it becomes a "root" view in angular's hierarchy - // } - // detachedLoaderRef.changeDetectorRef.detectChanges(); // force a change detection - // detachedLoaderRef.instance.createTemplatePortal(options.templateRef); - // } const targetView = new ContentView(); const portal = new ComponentPortal(options.type); portalOutlet = new NativeScriptDomPortalOutlet(targetView, this.appRef, childInjector); diff --git a/packages/angular/src/lib/legacy/router/page-router-outlet.ts b/packages/angular/src/lib/legacy/router/page-router-outlet.ts index 70a33816..187b9e42 100644 --- a/packages/angular/src/lib/legacy/router/page-router-outlet.ts +++ b/packages/angular/src/lib/legacy/router/page-router-outlet.ts @@ -1,9 +1,6 @@ import { ChangeDetectorRef, - ComponentFactory, - ComponentFactoryResolver, ComponentRef, - createComponent, Directive, ElementRef, EnvironmentInjector, @@ -63,10 +60,6 @@ export class PageRoute { } } -function isComponentFactoryResolver(item: any): item is ComponentFactoryResolver { - return !!item.resolveComponentFactory; -} - function callableOnce(fn: (...args: T[]) => void) { let called = false; return (...args: T[]) => { @@ -120,7 +113,6 @@ export class PageRouterOutlet implements OnDestroy, RouterOutletContract { private parentContexts = inject(ChildrenOutletContexts); private location = inject(ViewContainerRef); private locationStrategy = inject(NSLocationStrategy); - private resolver = inject(ComponentFactoryResolver); private changeDetector = inject(ChangeDetectorRef); private pageFactory = inject(PAGE_FACTORY); private routeReuseStrategy = inject(NSRouteReuseStrategy); @@ -132,8 +124,6 @@ export class PageRouterOutlet implements OnDestroy, RouterOutletContract { // tslint:disable-line:directive-class-suffix private activated: ComponentRef | null = null; private _activatedRoute: ActivatedRoute | null = null; - private detachedLoaderFactory: ComponentFactory; - private outlet: Outlet; private name: string; private isEmptyOutlet: boolean; @@ -193,7 +183,6 @@ export class PageRouterOutlet implements OnDestroy, RouterOutletContract { const name = inject(new HostAttributeToken('name'), { optional: true }); const actionBarVisibility = inject(new HostAttributeToken('actionBarVisibility'), { optional: true }); const isEmptyOutlet = !!inject(new HostAttributeToken('isEmptyOutlet'), { optional: true }); - const resolver = this.resolver; const elRef = inject(ElementRef); const viewUtil = inject(ViewUtil); @@ -208,7 +197,6 @@ export class PageRouterOutlet implements OnDestroy, RouterOutletContract { parentContexts.onChildOutletCreated(this.name, this); this.viewUtil = viewUtil; - this.detachedLoaderFactory = resolver.resolveComponentFactory(DetachedLoader); } setActionBarVisibility(actionBarVisibility: string): void { @@ -357,7 +345,7 @@ export class PageRouterOutlet implements OnDestroy, RouterOutletContract { * This method in turn is responsible for calling the `routerOnActivate` hook of its child. */ @profile - activateWith(activatedRoute: ActivatedRoute, resolver: ComponentFactoryResolver | EnvironmentInjector | null): void { + activateWith(activatedRoute: ActivatedRoute, environmentInjector: EnvironmentInjector | null): void { this.outlet = this.outlet || this.getOutlet(activatedRoute.snapshot); if (!this.outlet) { if (NativeScriptDebug.isLogEnabled()) { @@ -388,15 +376,12 @@ export class PageRouterOutlet implements OnDestroy, RouterOutletContract { this.markActivatedRoute(activatedRoute); - this.activateOnGoForward(activatedRoute, resolver || this.environmentInjector); + this.activateOnGoForward(activatedRoute, environmentInjector || this.environmentInjector); this.inputBinder?.bindActivatedRouteToOutletComponent(this); this.activateEvents.emit(this.activated.instance); } - private activateOnGoForward( - activatedRoute: ActivatedRoute, - resolverOrInjector: ComponentFactoryResolver | EnvironmentInjector, - ): void { + private activateOnGoForward(activatedRoute: ActivatedRoute, environmentInjector: EnvironmentInjector): void { if (NativeScriptDebug.isLogEnabled()) { NativeScriptDebug.routerLog( 'PageRouterOutlet.activate() forward navigation - ' + 'create detached loader in the loader container', @@ -424,16 +409,11 @@ export class PageRouterOutlet implements OnDestroy, RouterOutletContract { }); const injector = new DestructibleInjector(destructibles, parentInjector); - let loaderRef: ComponentRef; - if (isComponentFactoryResolver(resolverOrInjector)) { - loaderRef = createComponent(DetachedLoader, { - environmentInjector: this.environmentInjector, - elementInjector: injector, - }); - } else { - const environmentInjector = resolverOrInjector; - loaderRef = location.createComponent(DetachedLoader, { index: location.length, injector, environmentInjector }); - } + const loaderRef: ComponentRef = location.createComponent(DetachedLoader, { + index: location.length, + injector, + environmentInjector, + }); loaderRef.onDestroy(() => injector.destroy()); this.changeDetector.markForCheck(); From bf1389e614b3052a130fd9b5d41a8d403e9ceec5 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 17 Jun 2026 14:24:00 -0300 Subject: [PATCH 3/5] chore: remove unused detachedLoaderRef --- .../angular/src/lib/legacy/directives/dialogs.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/angular/src/lib/legacy/directives/dialogs.ts b/packages/angular/src/lib/legacy/directives/dialogs.ts index 82252bcb..86324f14 100644 --- a/packages/angular/src/lib/legacy/directives/dialogs.ts +++ b/packages/angular/src/lib/legacy/directives/dialogs.ts @@ -2,7 +2,6 @@ import { ApplicationRef, ComponentRef, Injectable, Injector, NgModuleRef, NgZone import { Application, ContentView, Frame, ShowModalOptions, View, ViewBase } from '@nativescript/core'; import { Subject } from 'rxjs'; import { AppHostAsyncView, AppHostView } from '../../app-host-view'; -import { DetachedLoader } from '../../cdk/detached-loader'; import { ComponentPortal } from '../../cdk/portal/common'; import { NativeScriptDomPortalOutlet } from '../../cdk/portal/nsdom-portal-outlet'; import { didModalOpen, once } from '../../utils/general'; @@ -117,7 +116,6 @@ export class ModalDialogService { private _showDialog(options: ShowDialogOptions): void { let componentViewRef: NgViewRef; - let detachedLoaderRef: ComponentRef; let portalOutlet: NativeScriptDomPortalOutlet; const closeCallback = once(async (...args) => { @@ -129,11 +127,9 @@ export class ModalDialogService { this._closed$.next(params); } await this.location._closeModalNavigation(); - if (detachedLoaderRef || portalOutlet) { + if (portalOutlet) { this.zone.run(() => { portalOutlet?.dispose(); - detachedLoaderRef?.instance.detectChanges(); - detachedLoaderRef?.destroy(); }); } } @@ -169,7 +165,7 @@ export class ModalDialogService { const modalView = componentViewRef.firstNativeLikeView; options.parentView.showModal(modalView, { ...options, closeCallback }); if (!didModalOpen(options.parentView as View, modalView)) { - this._handleFailedOpen(modalParams, portalOutlet, detachedLoaderRef); + this._handleFailedOpen(modalParams, portalOutlet); } }); } @@ -179,15 +175,13 @@ export class ModalDialogService { * failed to actually present it. Without this the modal navigation stack stays incremented * (blocking further navigation) and the attached view/loader leak on the `ApplicationRef`. */ - private _handleFailedOpen(modalParams: ModalDialogParams, portalOutlet?: NativeScriptDomPortalOutlet, detachedLoaderRef?: ComponentRef): never { + private _handleFailedOpen(modalParams: ModalDialogParams, portalOutlet?: NativeScriptDomPortalOutlet): never { const index = this.openedModalParams?.indexOf(modalParams) ?? -1; if (index > -1) { this.openedModalParams.splice(index, 1); } this.location?._closeModalNavigation(); portalOutlet?.dispose(); - detachedLoaderRef?.instance.detectChanges(); - detachedLoaderRef?.destroy(); throw new Error('Failed to open dialog: the modal view could not be presented. This usually happens when another modal is already being presented.'); } } From 4383dc8a714fecbc5cc3d0b428e2c02121545fe7 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 17 Jun 2026 15:43:43 -0300 Subject: [PATCH 4/5] fix: correctly identify if modal has opened or not --- packages/angular/src/lib/utils/general.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/angular/src/lib/utils/general.ts b/packages/angular/src/lib/utils/general.ts index 2d1fa01e..95b11a32 100644 --- a/packages/angular/src/lib/utils/general.ts +++ b/packages/angular/src/lib/utils/general.ts @@ -12,11 +12,24 @@ import { Application, View } from '@nativescript/core'; * @param modalView The view that was passed to `showModal()`. */ export function didModalOpen(parentView: View, modalView: View): boolean { - // On a successful present, core synchronously sets the parent's `modal` to the modal view. - if (parentView && parentView.modal === modalView) { + // Fast path: on a successful present, core sets `_modalParent` on the modal view itself, + // so we can confirm in O(1) without walking the view tree in the common (success) case. + if (modalView && (modalView as { _modalParent?: View })._modalParent) { return true; } + // Slow path (real confirmation): core sets `modal` to the modal view on the parent that owns + // a native view controller/fragment. On Android that's the exact `parentView`, but on iOS core + // walks up to the first ancestor with a view controller and sets it there, so we walk the + // parent chain to cover both platforms. + let view: View = parentView; + while (view) { + if (view.modal === modalView) { + return true; + } + view = view.parent as View; + } + // On Android, presenting while the app is backgrounded and the parent isn't loaded is // deferred until the parent loads again rather than failing, so treat it as opened. if (global.isAndroid && Application.inBackground && parentView && !parentView.isLoaded) { From b83e3159d2f00127c5c075eff4376581b1926028 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 17 Jun 2026 16:22:14 -0300 Subject: [PATCH 5/5] test: properly handle race conditions in modal tests --- .../src/tests/modal-dialog.spec.ts | 51 ++++++++++++++----- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/apps/nativescript-demo-ng/src/tests/modal-dialog.spec.ts b/apps/nativescript-demo-ng/src/tests/modal-dialog.spec.ts index 13dd6079..117710b9 100644 --- a/apps/nativescript-demo-ng/src/tests/modal-dialog.spec.ts +++ b/apps/nativescript-demo-ng/src/tests/modal-dialog.spec.ts @@ -2,10 +2,30 @@ import { Component, inject, NgModule, NO_ERRORS_SCHEMA, ViewContainerRef } from '@angular/core'; import { TestBed, waitForAsync } from '@angular/core/testing'; import { FrameService, ModalDialogParams, ModalDialogService, NativeScriptCommonModule, NSLocationStrategy, Outlet } from '@nativescript/angular'; -import { Frame, isIOS } from '@nativescript/core'; +import { Application, View } from '@nativescript/core'; import { FakeFrameService } from './ns-location-strategy.spec'; -const CLOSE_WAIT = isIOS ? 1000 : 0; + +/** + * Resolves once `condition` is truthy, polling on each frame. Unlike a fixed delay this resolves + * as soon as the awaited state is reached (e.g. a modal finishing its animated dismissal), with a + * bounded safety timeout so a stuck condition can't hang the suite. + */ +function waitUntil(condition: () => boolean, timeout = 5000): Promise { + return new Promise((resolve, reject) => { + const start = Date.now(); + const check = () => { + if (condition()) { + resolve(); + } else if (Date.now() - start > timeout) { + reject(new Error('Timed out waiting for condition')); + } else { + setTimeout(check, 16); + } + }; + check(); + }); +} @Component({ selector: 'modal-comp', @@ -75,17 +95,22 @@ describe('modal-dialog', () => { // done() // }); - afterEach((done) => { - const page = Frame.topmost().currentPage; - if (page && page.modal) { - console.log('Warning: closing a leftover modal page!'); - page.modal.closeModal(); - } - if (CLOSE_WAIT > 0) { - setTimeout(done, CLOSE_WAIT); - } else { - done(); - } + afterEach(async () => { + // Close any modal still presented (via core's global registry) and wait until it has actually + // finished dismissing before the next test runs. + // + // Note: `closeModal()` removes the modal from `_rootModalViews` *synchronously*, before the + // animated dismissal starts, so the registry being empty does NOT mean the modal is gone. On + // iOS the parent keeps a `presentedViewController` until the dismiss animation completes — and + // that's exactly what makes the next `showModal` fail with "already presenting" — so wait on it. + const open = ((Application.getRootView()?._getRootModalViews() ?? []) as View[]).slice(); + // Capture parents before closing: `closeModal()` nulls `_modalParent` synchronously. + const parents = open + .map((modal) => (modal as { _modalParent?: View })._modalParent) + .filter((parent): parent is View => !!parent); + open.forEach((modal) => modal.closeModal()); + const isPresenting = (parent: View) => !!(parent as { viewController?: { presentedViewController?: unknown } }).viewController?.presentedViewController; + await waitUntil(() => parents.every((parent) => !isPresenting(parent))).catch(() => undefined); }); it('showModal does not throws when there is no viewContainer provided', waitForAsync(async () => {