Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import {
Component,
ComponentFactory,
ComponentFactoryResolver,
ComponentRef,
EmbeddedViewRef,
inject,
Expand All @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 38 additions & 13 deletions apps/nativescript-demo-ng/src/tests/modal-dialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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',
Expand Down Expand Up @@ -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 () => {
Expand Down
9 changes: 5 additions & 4 deletions packages/angular/src/lib/cdk/detached-loader.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -41,8 +40,10 @@ export class DetachedLoader implements OnDestroy {
}

private loadInAppRef(componentType: Type<any>): ComponentRef<any> {
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(() => {
Expand Down
9 changes: 5 additions & 4 deletions packages/angular/src/lib/cdk/dialog/dialog-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Omit<ShowModalOptions, 'cancelable' | 'closeCallback'>>;
Expand Down Expand Up @@ -50,10 +50,11 @@ export class NativeDialogConfig<D = any> {
*/
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 = {};

Expand Down
14 changes: 3 additions & 11 deletions packages/angular/src/lib/cdk/dialog/dialog-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(config, dialogRef);
nativeModalRef.attachTemplatePortal(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
new TemplatePortal<T>(componentOrTemplateRef, null!, <any>{ $implicit: config.data, dialogRef }),
new TemplatePortal<T>(componentOrTemplateRef, null!, <any>{ $implicit: config.data, dialogRef }, injector),
);
} else {
const injector = this._createInjector<T>(config, dialogRef);
const contentRef = nativeModalRef.attachComponentPortal<T>(
new ComponentPortal(componentOrTemplateRef, config.viewContainerRef, injector),
new ComponentPortal(componentOrTemplateRef, config.viewContainerRef, injector, null, config.bindings),
);
dialogRef.componentInstance = contentRef.instance;
}
Expand Down
43 changes: 27 additions & 16 deletions packages/angular/src/lib/cdk/dialog/native-modal-ref.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -86,20 +86,16 @@ 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;
// if we don't detach the view from its parent, ios gets mad
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 () => {
Expand All @@ -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;
// }
Expand All @@ -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);
Expand All @@ -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 () => {
Expand All @@ -147,13 +142,29 @@ export class NativeModalRef {
},
cancelable: !this._config.disableClose,
});
if (!didModalOpen(this.parentView, modalView)) {
this._handleFailedOpen();
}
return componentRef;
}

_startExitAnimation() {
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();
}
Expand Down
48 changes: 35 additions & 13 deletions packages/angular/src/lib/cdk/portal/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -16,7 +16,7 @@ import type { ComponentType } from '../../utils/general';
* It can be attach to / detached from a `PortalOutlet`.
*/
export abstract class Portal<T> {
private _attachedHost: PortalOutlet | null;
private _attachedHost: PortalOutlet | null = null;

/** Attach this portal to a host. */
attach(host: PortalOutlet): T {
Expand Down Expand Up @@ -68,27 +68,45 @@ export class ComponentPortal<T> extends Portal<ComponentRef<T>> {
component: ComponentType<T>;

/**
* [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 `<ng-content>` of the attached component.
*/
componentFactoryResolver?: ComponentFactoryResolver | null;
projectableNodes?: View[][] | null;

constructor(component: ComponentType<T>, 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<unknown> | DirectiveWithBindings<unknown>)[] | null;

constructor(
component: ComponentType<T>,
viewContainerRef?: ViewContainerRef | null,
injector?: Injector | null,
projectableNodes?: View[][] | null,
bindings?: Binding[],
directives?: (Type<unknown> | DirectiveWithBindings<unknown>)[],
) {
super();
this.component = component;
this.viewContainerRef = viewContainerRef;
this.injector = injector;
this.componentFactoryResolver = componentFactoryResolver;
this.projectableNodes = projectableNodes;
this.bindings = bindings || null;
this.directives = directives || null;
}
}

Expand All @@ -105,11 +123,15 @@ export class TemplatePortal<C = any> extends Portal<EmbeddedViewRef<C>> {
/** Contextual data to be passed in to the embedded view. */
context: C | undefined;

constructor(template: TemplateRef<C>, viewContainerRef: ViewContainerRef, context?: C) {
/** The injector to use for the embedded view. */
injector: Injector | undefined;

constructor(template: TemplateRef<C>, viewContainerRef: ViewContainerRef, context?: C, injector?: Injector) {
super();
this.templateRef = template;
this.viewContainerRef = viewContainerRef;
this.context = context;
this.injector = injector;
}

get origin(): ElementRef {
Expand Down Expand Up @@ -168,13 +190,13 @@ export interface PortalOutlet {
*/
export abstract class BasePortalOutlet implements PortalOutlet {
/** The portal currently attached to the host. */
protected _attachedPortal: Portal<any> | null;
protected _attachedPortal: Portal<any> | 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 {
Expand Down
Loading
Loading