feat: modernize CDK portal/dialog internals and drop ComponentFactoryResolver by edusperoni · Pull Request #171 · NativeScript/angular · GitHub
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
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
Loading
Loading