diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index df2f0b41..f89bbe67 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -50,6 +50,7 @@ jobs: test-ios: name: Test (iOS) runs-on: macos-15 + timeout-minutes: 20 steps: - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1 @@ -74,6 +75,7 @@ jobs: test-android: name: Test (Android) runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..21dd69b1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,126 @@ +name: Release + +on: + workflow_dispatch: + inputs: + project: + description: Package to release + type: choice + options: [angular, zone-js] + default: angular + version: + description: Version specifier (e.g. 22.0.0, 22.0.0-rc.4, patch, minor) + required: true + dry-run: + description: Dry run (preview only, no push/publish) + type: boolean + default: true + publish-only: + description: Skip versioning and publish the version already committed (retry a failed publish) + type: boolean + default: false + +concurrency: + group: nx-release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: write # push release commit + tag, create GitHub release + id-token: write # npm provenance / trusted publishing (OIDC) + +env: + NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} + +jobs: + release: + runs-on: ubuntu-latest + environment: + name: ${{ inputs.dry-run && 'npm-publish-dry-run' || 'npm-publish' }} + steps: + - uses: actions/checkout@v6 + with: + # nx release needs tags + full history to build the changelog + fetch-depth: 0 + - uses: actions/setup-node@v6 + with: + node-version: lts/* + cache: npm + registry-url: https://registry.npmjs.org + - name: Update npm (required for OIDC trusted publishing) + run: | + npm install -g npm@^11.5.1 + npm --version + - run: npm install --force + - name: Configure git author + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + # Publishing is intentionally split out (--skip-publish) so the OIDC + # token-clearing logic below wraps only the publish step. + - name: nx release version + changelog + if: ${{ !inputs.publish-only }} + run: > + npx nx release ${{ inputs.version }} + --groups=${{ inputs.project }} + --skip-publish + ${{ inputs.dry-run && '--dry-run' || '' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Prereleases (e.g. 22.0.0-rc.4) publish to their prerelease dist-tag (rc), + # never latest. In a dry run keyword specifiers (patch/minor) resolve from + # the unbumped manifest, so the previewed tag may differ from a real run. + - name: Resolve npm dist-tag + id: dist-tag + shell: bash + run: | + set -euo pipefail + spec='${{ inputs.version }}' + if [[ "$spec" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-.+)?$ ]]; then + version="$spec" + else + version=$(node -p "require('./packages/${{ inputs.project }}/package.json').version") + fi + if [[ "$version" == *-* ]]; then + pre="${version#*-}" + tag="${pre%%.*}" + if [[ "$tag" =~ ^[0-9]+$ ]]; then + tag="next" + fi + else + tag="v21-lts" + fi + echo "Resolved version ${version} -> dist-tag ${tag}" + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + # OIDC trusted publishing (default): npm must find no token auth at all, + # otherwise it uses the token instead of the OIDC exchange. + - name: nx release publish (OIDC) + if: ${{ vars.USE_NPM_TOKEN != 'true' }} + shell: bash + env: + NPM_CONFIG_PROVENANCE: true + NODE_AUTH_TOKEN: "" + run: | + set -euo pipefail + unset NODE_AUTH_TOKEN + rm -f ~/.npmrc || true + if [[ -n "${NPM_CONFIG_USERCONFIG:-}" ]]; then + rm -f "$NPM_CONFIG_USERCONFIG" || true + fi + + npx nx release publish \ + --groups=${{ inputs.project }} \ + --tag "${{ steps.dist-tag.outputs.tag }}" \ + --access public \ + ${{ inputs.dry-run && '--dry-run' || '' }} + # Token fallback: only when explicitly enabled via repo variable USE_NPM_TOKEN=true. + - name: nx release publish (token) + if: ${{ vars.USE_NPM_TOKEN == 'true' }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_PROVENANCE: true + run: > + npx nx release publish + --groups=${{ inputs.project }} + --tag "${{ steps.dist-tag.outputs.tag }}" + --access public + ${{ inputs.dry-run && '--dry-run' || '' }} diff --git a/apps/nativescript-demo-ng/src/tests/image-config.spec.ts b/apps/nativescript-demo-ng/src/tests/image-config.spec.ts new file mode 100644 index 00000000..f4ff3131 --- /dev/null +++ b/apps/nativescript-demo-ng/src/tests/image-config.spec.ts @@ -0,0 +1,20 @@ +import { IMAGE_CONFIG } from '@angular/common'; +import { TestBed } from '@angular/core/testing'; +import { NativeScriptModule } from '@nativescript/angular'; + +describe('IMAGE_CONFIG', () => { + beforeEach(() => { + return TestBed.configureTestingModule({ + imports: [NativeScriptModule], + }).compileComponents(); + }); + + // Both flags are required: with either one unset, Angular reaches for `document` during + // bootstrap on any runtime that exposes a global PerformanceObserver. + it('disables both dev-mode image warnings', () => { + const config = TestBed.inject(IMAGE_CONFIG); + + expect(config.disableImageSizeWarning).toBe(true); + expect(config.disableImageLazyLoadWarning).toBe(true); + }); +}); 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 117710b9..ce4f11a6 100644 --- a/apps/nativescript-demo-ng/src/tests/modal-dialog.spec.ts +++ b/apps/nativescript-demo-ng/src/tests/modal-dialog.spec.ts @@ -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 { - 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', @@ -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); diff --git a/apps/nativescript-demo-ng/src/tests/native-dialog.spec.ts b/apps/nativescript-demo-ng/src/tests/native-dialog.spec.ts new file mode 100644 index 00000000..d092b22c --- /dev/null +++ b/apps/nativescript-demo-ng/src/tests/native-dialog.spec.ts @@ -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: ``, + imports: [NativeScriptCommonModule], + schemas: [NO_ERRORS_SCHEMA], +}) +export class DialogContentComponent { + ref = inject(NativeDialogRef); +} + +@Component({ + selector: 'dialog-host-comp', + template: ` + + + `, + imports: [NativeScriptCommonModule], + schemas: [NO_ERRORS_SCHEMA], +}) +export class DialogHostComponent { + dialog = inject(NativeDialogService); + vcRef = inject(ViewContainerRef); + @ViewChild('dialogTemplate', { static: true }) dialogTemplate: TemplateRef; +} + +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 { + 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, + ); +}); diff --git a/apps/nativescript-demo-ng/src/tests/test-utils.spec.ts b/apps/nativescript-demo-ng/src/tests/test-utils.spec.ts index d2009185..a4de7e32 100644 --- a/apps/nativescript-demo-ng/src/tests/test-utils.spec.ts +++ b/apps/nativescript-demo-ng/src/tests/test-utils.spec.ts @@ -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'; @@ -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 { + 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 { + 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, diff --git a/packages/angular/CHANGELOG.md b/packages/angular/CHANGELOG.md index 39ea524b..91afa3e9 100644 --- a/packages/angular/CHANGELOG.md +++ b/packages/angular/CHANGELOG.md @@ -1,3 +1,28 @@ +## 21.2.0 (2026-10-02) + +### 🚀 Features + +- update cdk-inspired components and drop ComponentFactoryResolver ([4c8f696](https://github.com/NativeScript/angular/commit/4c8f696)) +- throw error on failed to open dialog and refactor some deprecated usages ([d24b766](https://github.com/NativeScript/angular/commit/d24b766)) +- make it easier to configure custom flags ([e0dab64](https://github.com/NativeScript/angular/commit/e0dab64)) +- enable option to wrap CD in CATransaction ([96d4c0e](https://github.com/NativeScript/angular/commit/96d4c0e)) +- expose BootstrapContext on bootstrapApplication ([4114bd3](https://github.com/NativeScript/angular/commit/4114bd3)) +- use upstream angular router link ([#166](https://github.com/NativeScript/angular/pull/166)) +- add router input binding ([#165](https://github.com/NativeScript/angular/pull/165)) +- allow custom injector in dialogs and handle deprecations ([#140](https://github.com/NativeScript/angular/pull/140)) +- angular 21.2.9 ([59c8899](https://github.com/NativeScript/angular/commit/59c8899)) +- expose createApplication and allow passing in a platformRef ([00f319c](https://github.com/NativeScript/angular/commit/00f319c)) + +### 🩹 Fixes + +- emit afterClosed only after the native modal dismissal completes ([#179](https://github.com/NativeScript/angular/pull/179)) +- answer DOM probes on NativeScriptDocument instead of throwing ([#176](https://github.com/NativeScript/angular/pull/176)) +- bootstrap NG0210 when the runtime exposes a global PerformanceObserver ([#178](https://github.com/NativeScript/angular/pull/178)) +- correctly identify if modal has opened or not ([6a6f90c](https://github.com/NativeScript/angular/commit/6a6f90c)) +- correctly use the `PREVENT_CHANGE_EVENTS_DURING_CD` token to gate change events ([#174](https://github.com/NativeScript/angular/pull/174)) +- set tagName on NgView for Angular 21 bootstrap compatibility ([#168](https://github.com/NativeScript/angular/pull/168)) +- don't dispose the portal on component destroy ([7de9f41](https://github.com/NativeScript/angular/commit/7de9f41)) + # [21.0.0](https://github.com/NativeScript/angular/compare/20.1.1...21.0.0) (2026-01-04) diff --git a/packages/angular/package.json b/packages/angular/package.json index 6c52fde2..98972b14 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@nativescript/angular", - "version": "21.0.0", + "version": "21.2.0", "homepage": "https://nativescript.org/", "repository": { "type": "git", diff --git a/packages/angular/src/lib/cdk/dialog/dialog-ref.ts b/packages/angular/src/lib/cdk/dialog/dialog-ref.ts index 79c4ab04..1310ea6e 100644 --- a/packages/angular/src/lib/cdk/dialog/dialog-ref.ts +++ b/packages/angular/src/lib/cdk/dialog/dialog-ref.ts @@ -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, @@ -31,7 +39,7 @@ export class NativeDialogRef { /** 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. */ @@ -82,7 +90,6 @@ export class NativeDialogRef { close(dialogResult?: R): void { this._result = dialogResult; - // Transition the backdrop in parallel to the dialog. this._nativeModalRef.stateChanged .pipe( filter((event) => event.state === 'closing'), @@ -92,22 +99,12 @@ export class NativeDialogRef { 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; 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 e2a8f6de..3852d237 100644 --- a/packages/angular/src/lib/cdk/dialog/native-modal-ref.ts +++ b/packages/angular/src/lib/cdk/dialog/native-modal-ref.ts @@ -1,7 +1,6 @@ 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 { Subject } from 'rxjs'; import { AppHostAsyncView, AppHostView } from '../../app-host-view'; import { NSLocationStrategy } from '../../legacy/router/ns-location-strategy'; import { didModalOpen, once } from '../../utils/general'; @@ -49,20 +48,11 @@ export class NativeModalRef { } this.parentView = parentView; - this._closeCallback = once(async () => { + this._closeCallback = once(() => { this.stateChanged.next({ state: 'closing' }); if (!this._isDismissed) { this.modalViewRef.firstNativeLikeView?.closeModal(); } - await this.location?._closeModalNavigation(); - // this.detachedLoaderRef?.destroy(); - if (this.modalViewRef?.firstNativeLikeView.isLoaded) { - fromEvent(this.modalViewRef.firstNativeLikeView, 'unloaded') - .pipe(take(1)) - .subscribe(() => this.stateChanged.next({ state: 'closed' })); - } else { - this.stateChanged.next({ state: 'closed' }); - } }); } @@ -93,21 +83,7 @@ export class NativeModalRef { // if we don't detach the view from its parent, ios gets mad this.modalViewRef.detachNativeLikeView(); - const userOptions = this._config.nativeOptions || {}; - const modalView = this.modalViewRef.firstNativeLikeView; - this.parentView.showModal(modalView, { - context: null, - ...userOptions, - closeCallback: async () => { - await this.location?._closeModalNavigation(); - this.onDismiss.next(); - this.onDismiss.complete(); - }, - cancelable: !this._config.disableClose, - }); - if (!didModalOpen(this.parentView, modalView)) { - this._handleFailedOpen(); - } + this._showModal(this.modalViewRef.firstNativeLikeView); // if (this.modalView !== templateRef.rootNodes[0]) { // componentRef.location.nativeElement._ngDialogRoot = this.modalView; // } @@ -129,27 +105,52 @@ export class NativeModalRef { // if we don't detach the view from its parent, ios gets mad this.modalViewRef.detachNativeLikeView(); + this._showModal(this.modalViewRef.firstNativeLikeView); + return componentRef; + } + + _startExitAnimation() { + this._closeCallback(); + } + + private _showModal(modalView: View): void { + modalView.once(View.shownModallyEvent, () => this.stateChanged.next({ state: 'opened' })); const userOptions = this._config.nativeOptions || {}; - const modalView = this.modalViewRef.firstNativeLikeView; this.parentView.showModal(modalView, { context: null, ...userOptions, - closeCallback: async () => { - this._isDismissed = true; - this._closeCallback(); // close callback can only be called once, so we call it here to setup the exit events - this.onDismiss.next(); - this.onDismiss.complete(); - }, + closeCallback: () => this._onDismissed(), cancelable: !this._config.disableClose, }); if (!didModalOpen(this.parentView, modalView)) { this._handleFailedOpen(); } - return componentRef; } - _startExitAnimation() { + /** + * Runs when core reports the native dismissal as complete (the `showModal` closeCallback — + * on iOS the `dismissViewControllerAnimated` completion handler). The `unloaded` listener + * must not be attached before this point: a view also unloads for unrelated reasons (e.g. + * the app going to the background), which would signal 'closed' while the modal is still + * presented and make the next `showModal` fail. + */ + private async _onDismissed(): Promise { + this._isDismissed = true; + // Emits 'closing' when the dismissal was native-initiated (back button, swipe-down) + // and NativeDialogRef.close() was never called. this._closeCallback(); + + // Core tears the view down right after this callback returns, so the listener has to be + // attached synchronously, before any await. + const modalView = this.modalViewRef?.firstNativeLikeView; + const whenUnloaded = modalView?.isLoaded ? new Promise((resolve) => modalView.once(View.unloadedEvent, () => resolve())) : Promise.resolve(); + + await this.location?._closeModalNavigation(); + this.onDismiss.next(); + this.onDismiss.complete(); + + await whenUnloaded; + this.stateChanged.next({ state: 'closed' }); } /** diff --git a/packages/angular/src/lib/nativescript.ts b/packages/angular/src/lib/nativescript.ts index 7fb656d8..1b5cb764 100644 --- a/packages/angular/src/lib/nativescript.ts +++ b/packages/angular/src/lib/nativescript.ts @@ -1,5 +1,5 @@ -import { ViewportScroller, XhrFactory, ɵNullViewportScroller as NullViewportScroller } from '@angular/common'; -import { ApplicationModule, ErrorHandler, Inject, NgModule, NO_ERRORS_SCHEMA, Optional, Provider, RendererFactory2, SkipSelf, StaticProvider, ɵINJECTOR_SCOPE as INJECTOR_SCOPE } from '@angular/core'; +import { IMAGE_CONFIG, ViewportScroller, XhrFactory, ɵNullViewportScroller as NullViewportScroller } from '@angular/common'; +import { ApplicationModule, CSP_NONCE, ErrorHandler, Inject, NgModule, NO_ERRORS_SCHEMA, Optional, Provider, RendererFactory2, SkipSelf, StaticProvider, ɵINJECTOR_SCOPE as INJECTOR_SCOPE } from '@angular/core'; import { Color, Device, View } from '@nativescript/core'; import { AppHostView } from './app-host-view'; import { NativescriptXhrFactory } from './nativescript-xhr-factory'; @@ -39,8 +39,18 @@ export const NATIVESCRIPT_MODULE_STATIC_PROVIDERS: StaticProvider[] = [ { provide: NAMESPACE_FILTERS, useClass: PlatformNamespaceFilter, deps: [DEVICE], multi: true }, { provide: DEVICE, useValue: Device }, { provide: XhrFactory, useClass: NativescriptXhrFactory, deps: [] }, + // No CSP in a NativeScript runtime. Providing it also stops the token's default + // factory reading `DOCUMENT.body.querySelector()`. Must stay root-scoped: the root + // injector resolves `providedIn: 'root'` tokens before consulting the platform. + { provide: CSP_NONCE, useValue: null }, +]; +export const NATIVESCRIPT_MODULE_PROVIDERS: Provider[] = [ + { provide: ViewportScroller, useClass: NullViewportScroller }, + // Angular's dev-mode image warnings scan the DOM for elements, which + // NativeScript has none of. Angular only skips them when both flags are set, + // and reaches for `document` as soon as a global PerformanceObserver exists. + { provide: IMAGE_CONFIG, useValue: { disableImageSizeWarning: true, disableImageLazyLoadWarning: true } }, ]; -export const NATIVESCRIPT_MODULE_PROVIDERS: Provider[] = [{ provide: ViewportScroller, useClass: NullViewportScroller }]; @NgModule({ imports: [ApplicationModule, DetachedLoader, NativeScriptCommonModule], diff --git a/packages/angular/src/lib/platform-nativescript.ts b/packages/angular/src/lib/platform-nativescript.ts index 321174f3..3989a1ff 100644 --- a/packages/angular/src/lib/platform-nativescript.ts +++ b/packages/angular/src/lib/platform-nativescript.ts @@ -37,12 +37,41 @@ export class NativeScriptSanitizer extends Sanitizer { // }; // } +/** + * Angular probes the DOM to discover optional configuration it may have been handed + * out of band - a `ngCspNonce` attribute on ``, a transfer state `