Skip to content
Merged
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
41 changes: 2 additions & 39 deletions apps/nativescript-demo-ng/src/tests/modal-dialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,9 @@
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 { Application, View } from '@nativescript/core';

import { FakeFrameService } from './ns-location-strategy.spec';

/**
* 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();
});
}
import { closeRemainingModals } from './test-utils.spec';

@Component({
selector: 'modal-comp',
Expand Down Expand Up @@ -95,23 +74,7 @@ describe('modal-dialog', () => {
// 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);
});
afterEach(() => closeRemainingModals());

it('showModal does not throws when there is no viewContainer provided', waitForAsync(async () => {
const fixture = TestBed.createComponent(FailComponent);
Expand Down
123 changes: 123 additions & 0 deletions apps/nativescript-demo-ng/src/tests/native-dialog.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { Component, inject, NO_ERRORS_SCHEMA, TemplateRef, ViewChild, ViewContainerRef } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { FrameService, NativeDialogRef, NativeDialogService, NativeScriptCommonModule, NSLocationStrategy } from '@nativescript/angular';
import { View } from '@nativescript/core';
import { firstValueFrom } from 'rxjs';

import { FakeFrameService } from './ns-location-strategy.spec';
import { closeRemainingModals, isPresentingModally, topRootModalView } from './test-utils.spec';

@Component({
selector: 'dialog-content-comp',
template: `<GridLayout><Label text="dialog content"></Label></GridLayout>`,
imports: [NativeScriptCommonModule],
schemas: [NO_ERRORS_SCHEMA],
})
export class DialogContentComponent {
ref = inject(NativeDialogRef<DialogContentComponent>);
}

@Component({
selector: 'dialog-host-comp',
template: `<GridLayout>
<Label text="dialog host"></Label>
<ng-template #dialogTemplate><Label text="template dialog content"></Label></ng-template>
</GridLayout>`,
imports: [NativeScriptCommonModule],
schemas: [NO_ERRORS_SCHEMA],
})
export class DialogHostComponent {
dialog = inject(NativeDialogService);
vcRef = inject(ViewContainerRef);
@ViewChild('dialogTemplate', { static: true }) dialogTemplate: TemplateRef<unknown>;
}

describe('native-dialog', () => {
beforeEach(() => {
return TestBed.configureTestingModule({
imports: [DialogHostComponent, DialogContentComponent, NativeScriptCommonModule],
providers: [{ provide: FrameService, useValue: new FakeFrameService() }, NSLocationStrategy],
}).compileComponents();
});

afterEach(() => closeRemainingModals());

async function createHost(): Promise<DialogHostComponent> {
const fixture = TestBed.createComponent(DialogHostComponent);
fixture.detectChanges();
await fixture.whenRenderingDone();
return fixture.componentRef.instance;
}

it(
'afterOpened emits once the modal is fully presented',
async () => {
const host = await createHost();
const ref = host.dialog.open(DialogContentComponent, { viewContainerRef: host.vcRef });
await firstValueFrom(ref.afterOpened());
const closed = firstValueFrom(ref.afterClosed());
ref.close();
await closed;
},
10000,
);

it(
'afterClosed emits only after the native dismissal completes, so a new dialog can open immediately',
async () => {
const host = await createHost();
const hostView: View = host.vcRef.element.nativeElement;

const firstRef = host.dialog.open(DialogContentComponent, { viewContainerRef: host.vcRef });
await firstValueFrom(firstRef.afterOpened());

const events: string[] = [];
firstRef.beforeClosed().subscribe(() => events.push('beforeClosed'));
firstRef.afterClosed().subscribe(() => events.push('afterClosed'));

const closed = firstValueFrom(firstRef.afterClosed());
firstRef.close('first result');
expect(await closed).toEqual('first result');
expect(events).toEqual(['beforeClosed', 'afterClosed']);
// On iOS a premature afterClosed would leave the parent still presenting the old
// view controller, which is what makes the next showModal fail.
expect(isPresentingModally(hostView)).toBe(false);

const secondRef = host.dialog.open(DialogContentComponent, { viewContainerRef: host.vcRef });
await firstValueFrom(secondRef.afterOpened());
const secondClosed = firstValueFrom(secondRef.afterClosed());
secondRef.close();
await secondClosed;
},
15000,
);

it(
'afterClosed emits when a component dialog is dismissed natively',
async () => {
const host = await createHost();
const ref = host.dialog.open(DialogContentComponent, { viewContainerRef: host.vcRef });
await firstValueFrom(ref.afterOpened());

const closed = firstValueFrom(ref.afterClosed());
// Dismiss through core, like the user swiping down or pressing back.
topRootModalView().closeModal();
await closed;
},
10000,
);

it(
'afterClosed emits when a template dialog is dismissed natively',
async () => {
const host = await createHost();
const ref = host.dialog.open(host.dialogTemplate, { viewContainerRef: host.vcRef });
await firstValueFrom(ref.afterOpened());

const closed = firstValueFrom(ref.afterClosed());
topRootModalView().closeModal();
await closed;
},
10000,
);
});
62 changes: 62 additions & 0 deletions apps/nativescript-demo-ng/src/tests/test-utils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Application } from '@nativescript/core';
import { View } from '@nativescript/core/ui/core/view';
import { TextBase } from '@nativescript/core/ui/text-base';
import { Device } from '@nativescript/core/platform';
Expand Down Expand Up @@ -37,6 +38,67 @@ export function dumpView(view: View, verbose: boolean = false): string {
return output.join('');
}

/**
* 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.
*/
export 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();
});
}

/**
* Returns true while any ancestor view controller of `view` is still presenting modally.
* Only meaningful on iOS; on Android there is no `viewController` and this returns false.
*/
export function isPresentingModally(view: View): boolean {
let current = view;
while (current) {
if ((current as { viewController?: { presentedViewController?: unknown } }).viewController?.presentedViewController) {
return true;
}
current = current.parent as View;
}
return false;
}

/** The most recently presented modal view still tracked in core's global registry. */
export function topRootModalView(): View | undefined {
const modals = ((Application.getRootView()?._getRootModalViews() ?? []) as View[]).slice();
return modals[modals.length - 1];
}

/**
* 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.
*/
export async function closeRemainingModals(): Promise<void> {
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());
await waitUntil(() => parents.every((parent) => !isPresentingModally(parent))).catch(() => undefined);
}

export function createDevice(os: string): typeof Device {
return {
os: os,
Expand Down
33 changes: 15 additions & 18 deletions packages/angular/src/lib/cdk/dialog/dialog-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ import { NativeModalRef } from './native-modal-ref';
// Counter for unique dialog ids.
let uniqueId = 0;

/**
* Safety-net delay before `afterClosed` is forced when the native dismissal never reports
* completion (e.g. the parent view is destroyed mid-animation). Must exceed the longest
* modal dismiss animation, including custom transitions, so it can't preempt a normal
* close — the 'closed' state emitted after the native dismissal is the real trigger.
*/
const CLOSE_FALLBACK_TIMEOUT = 5000;

/** Possible states of the lifecycle of a dialog. */
export const enum NativeDialogState {
OPEN,
Expand All @@ -31,7 +39,7 @@ export class NativeDialogRef<T, R = any> {
/** Result to be passed to afterClosed. */
private _result: R | undefined;

/** Handle to the timeout that's running as a fallback in case the exit animation doesn't fire. */
/** Handle to the safety-net timeout in case the native dismissal never reports completion. */
private _closeFallbackTimeout: any;

/** Current state of the dialog. */
Expand Down Expand Up @@ -82,7 +90,6 @@ export class NativeDialogRef<T, R = any> {
close(dialogResult?: R): void {
this._result = dialogResult;

// Transition the backdrop in parallel to the dialog.
this._nativeModalRef.stateChanged
.pipe(
filter((event) => event.state === 'closing'),
Expand All @@ -92,22 +99,12 @@ export class NativeDialogRef<T, R = any> {
this._beforeClosed.next(dialogResult);
this._beforeClosed.complete();
this._nativeModalRef.dispose();
// this._overlayRef.detachBackdrop();

// The logic that disposes of the overlay depends on the exit animation completing, however
// it isn't guaranteed if the parent view is destroyed while it's running. Add a fallback
// timeout which will clean everything up if the animation hasn't fired within the specified
// amount of time plus 100ms. We don't need to run this outside the NgZone, because for the
// vast majority of cases the timeout will have been cleared before it has the chance to fire.
this._closeFallbackTimeout = setTimeout(
() => {
this._finishDialogClose();
this._afterClosed.next(this._result);
this._afterClosed.complete();
},
//event.totalTime + 100);
100
);

this._closeFallbackTimeout = setTimeout(() => {
this._finishDialogClose();
this._afterClosed.next(this._result);
this._afterClosed.complete();
}, CLOSE_FALLBACK_TIMEOUT);
});

this._state = NativeDialogState.CLOSING;
Expand Down
Loading
Loading