Skip to content
Draft
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
24,712 changes: 14,108 additions & 10,604 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/angular/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nativescript/angular",
"version": "21.0.0",
"version": "21.0.1-alpha.3",
"homepage": "https://nativescript.org/",
"repository": {
"type": "git",
Expand Down
421 changes: 368 additions & 53 deletions packages/angular/src/lib/application.ts

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions packages/angular/src/lib/cdk/dialog/dialog-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,22 @@ export class NativeDialogConfig<D = any> {

nativeOptions?: NativeShowModalOptions = {};

/**
* When true, this dialog will be re-opened automatically on Angular HMR
* reboots so the user does not lose context every time a related file
* changes. The new dialog reuses the same component class and `data` payload
* (provided via `data`); other config such as `nativeOptions` is preserved
* verbatim.
*
* The original `dialogRef.afterClosed()` subject is wired to the restored
* dialog so consumers `await openModal(...)` resolve normally when the user
* eventually closes the restored modal.
*
* Only opens via component class are restorable — `TemplateRef` openings
* carry references that don't survive an HMR reboot and are silently
* skipped. Has no effect outside of HMR.
*/
preserveOnHmr?: boolean = false;

// TODO(jelbourn): add configuration for lifecycle hooks, ARIA labelling.
}
123 changes: 123 additions & 0 deletions packages/angular/src/lib/cdk/dialog/dialog-hmr-animation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { NativeDialogConfig } from './dialog-config';
import { buildNonAnimatedRestoreConfig, suppressNativeCloseAnimation } from './dialog-hmr-animation';
import { HmrCandidateDialog } from './dialog-hmr';

class StubComponent {}

function makeCandidate(opts: {
parentView?: { _modalAnimatedOptions?: boolean[] };
preserveOnHmr?: boolean;
}): HmrCandidateDialog {
const config = new NativeDialogConfig();
config.preserveOnHmr = opts.preserveOnHmr ?? true;
const ref: unknown = {
_nativeModalRef: opts.parentView ? { parentView: opts.parentView } : undefined,
};
return {
ref: ref as HmrCandidateDialog['ref'],
componentClass: StubComponent as unknown as HmrCandidateDialog['componentClass'],
config,
};
}

describe('NativeDialog HMR animation helpers', () => {
describe('suppressNativeCloseAnimation', () => {
it('flips the top of the parent view animated stack to false so the next dismiss is un-animated', () => {
const stack: boolean[] = [true];
const candidate = makeCandidate({ parentView: { _modalAnimatedOptions: stack } });

suppressNativeCloseAnimation(candidate);

expect(stack).toEqual([false]);
});

it('only mutates the top entry so deeper presentations stay untouched', () => {
const stack: boolean[] = [true, true];
const candidate = makeCandidate({ parentView: { _modalAnimatedOptions: stack } });

suppressNativeCloseAnimation(candidate);

// The dismiss reads `slice(-1)[0]`; deeper entries belong to other
// open modals on the same parent view and must stay animated.
expect(stack).toEqual([true, false]);
});

it('skips the mutation when the candidate did not opt into preservation', () => {
const stack: boolean[] = [true];
const candidate = makeCandidate({
parentView: { _modalAnimatedOptions: stack },
preserveOnHmr: false,
});

suppressNativeCloseAnimation(candidate);

expect(stack).toEqual([true]);
});

it('is a no-op when the underlying native modal ref is missing', () => {
const candidate = makeCandidate({ parentView: undefined });

expect(() => suppressNativeCloseAnimation(candidate)).not.toThrow();
});

it('is a no-op when the parent view exposes no animated stack', () => {
const candidate = makeCandidate({ parentView: {} });

expect(() => suppressNativeCloseAnimation(candidate)).not.toThrow();
});

it('is a no-op when the animated stack is present but empty', () => {
const candidate = makeCandidate({ parentView: { _modalAnimatedOptions: [] } });

expect(() => suppressNativeCloseAnimation(candidate)).not.toThrow();
});
});

describe('buildNonAnimatedRestoreConfig', () => {
it('returns a NativeDialogConfig with nativeOptions.animated forced to false', () => {
const original = new NativeDialogConfig();
original.nativeOptions = { animated: true, fullscreen: true } as never;

const restore = buildNonAnimatedRestoreConfig(original);

expect((restore.nativeOptions as Record<string, unknown>)?.animated).toBe(false);
expect((restore.nativeOptions as Record<string, unknown>)?.fullscreen).toBe(true);
});

it('does not mutate the original config so cached references stay intact', () => {
const original = new NativeDialogConfig();
original.nativeOptions = { animated: true } as never;

const restore = buildNonAnimatedRestoreConfig(original);

expect(restore).not.toBe(original);
expect((original.nativeOptions as Record<string, unknown>)?.animated).toBe(true);
});

it('synthesises a nativeOptions object when the original config has none', () => {
const original = new NativeDialogConfig();
// Default config initialiser sets nativeOptions to {}, replicate
// the shape projects produce when they explicitly set it to
// undefined for some opens.
original.nativeOptions = undefined;

const restore = buildNonAnimatedRestoreConfig(original);

expect(restore.nativeOptions).toEqual({ animated: false });
expect(original.nativeOptions).toBeUndefined();
});

it('preserves the rest of the captured config (data, id, preserveOnHmr) so the reopened modal looks identical to the user', () => {
const original = new NativeDialogConfig();
original.id = 'resource-modal';
original.data = { resourceId: 42 };
original.preserveOnHmr = true;

const restore = buildNonAnimatedRestoreConfig(original);

expect(restore.id).toBe('resource-modal');
expect(restore.data).toEqual({ resourceId: 42 });
expect(restore.preserveOnHmr).toBe(true);
});
});
});
62 changes: 62 additions & 0 deletions packages/angular/src/lib/cdk/dialog/dialog-hmr-animation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { NativeDialogConfig } from './dialog-config';
import { HmrCandidateDialog } from './dialog-hmr';

/**
* Best-effort animation helpers used by the dialog HMR layer to make
* the close + reopen round-trip feel like an in-place content refresh.
*
* They live in a tiny standalone module on purpose:
*
* - `dialog-services.ts` pulls in `@angular/core`, which Jest cannot
* load in our spec runner without an extra ESM transform. By
* keeping these helpers free of `@angular/core` we can unit-test
* them in isolation (`dialog-hmr-animation.spec.ts`) while
* `dialog-services.ts` re-exports them at the public API layer.
* - The helpers are inherently best-effort: a missing
* `_nativeModalRef`, a frozen `_modalAnimatedOptions` stack, or a
* future `NativeDialogConfig` shape change must never break HMR
* restore — we just fall back to the original animated behavior.
*/

/**
* Mutate the top of `parentView._modalAnimatedOptions` to `false` for
* the given candidate so the imminent native close runs un-animated.
*
* iOS reads `_modalAnimatedOptions.slice(-1)[0]` when dismissing a
* modal (see core `view-common.ts` / `view/index.ios.ts`). The
* Angular dialog service only pushes one entry per open call, so the
* top entry is the exact flag that controls the dismiss we're about
* to trigger as part of the HMR root-view replacement.
*/
export function suppressNativeCloseAnimation(candidate: HmrCandidateDialog): void {
if (!candidate.config?.preserveOnHmr) {
return;
}
try {
const modalRef = (candidate.ref as unknown as { _nativeModalRef?: { parentView?: unknown } })?._nativeModalRef;
const parentView = modalRef?.parentView as { _modalAnimatedOptions?: boolean[] } | undefined;
const stack = parentView?._modalAnimatedOptions;
if (Array.isArray(stack) && stack.length > 0) {
stack[stack.length - 1] = false;
}
} catch {
// Swallow: a missing `_nativeModalRef` / `_modalAnimatedOptions`
// is acceptable — we just lose the no-animation optimisation.
}
}

/**
* Build a `NativeDialogConfig` clone of `original` whose
* `nativeOptions.animated` is forced to `false`. Used when re-opening
* a captured modal so the open animation matches the suppressed
* close — together they make the HMR round-trip feel like a content
* refresh instead of a close/reopen.
*/
export function buildNonAnimatedRestoreConfig(original: NativeDialogConfig): NativeDialogConfig {
// Clone via `Object.assign` so consumers holding the original
// config (e.g. caching it for re-open) don't see mutations from
// the HMR pathway.
const cloned = Object.assign(new NativeDialogConfig(), original) as NativeDialogConfig;
cloned.nativeOptions = { ...(original?.nativeOptions || {}), animated: false };
return cloned;
}
144 changes: 144 additions & 0 deletions packages/angular/src/lib/cdk/dialog/dialog-hmr.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { Subject } from 'rxjs';
import {
abortCapturedDialog,
captureDialogsForHmr,
clearPendingHmrDialogs,
consumePendingHmrDialogs,
HmrCandidateDialog,
peekPendingHmrDialogs,
selectPreservableDialogs,
} from './dialog-hmr';
import { NativeDialogConfig } from './dialog-config';
import { NativeDialogRef } from './dialog-ref';

class StubComponent {}
class OtherStubComponent {}

function makeRef(afterClosed: Subject<unknown>): NativeDialogRef<unknown> {
return { _afterClosed: afterClosed } as unknown as NativeDialogRef<unknown>;
}

function makeCandidate(opts: { component?: typeof StubComponent | typeof OtherStubComponent; preserveOnHmr?: boolean; subject?: Subject<unknown> } = {}): HmrCandidateDialog {
const config = new NativeDialogConfig();
config.preserveOnHmr = opts.preserveOnHmr;
const subject = opts.subject ?? new Subject<unknown>();
return {
ref: makeRef(subject),
componentClass: opts.component as any,
config,
};
}

describe('dialog-hmr', () => {
afterEach(() => {
clearPendingHmrDialogs();
});

describe('selectPreservableDialogs', () => {
it('keeps only dialogs marked preserveOnHmr that have a real component class', () => {
const a = makeCandidate({ component: StubComponent, preserveOnHmr: true });
const b = makeCandidate({ component: StubComponent, preserveOnHmr: false });
const c = makeCandidate({ component: undefined, preserveOnHmr: true });

expect(selectPreservableDialogs([a, b, c])).toEqual([a]);
});
});

describe('captureDialogsForHmr', () => {
it('stashes preservable dialogs onto globalThis so the next bootstrap can pick them up', () => {
const subject = new Subject<unknown>();
const candidate = makeCandidate({ component: StubComponent, preserveOnHmr: true, subject });

const captured = captureDialogsForHmr([candidate]);

expect(captured).toHaveLength(1);
expect(captured[0].componentClass).toBe(StubComponent);
expect(peekPendingHmrDialogs()).toHaveLength(1);
});

it('captures the source class name so post-reboot restore can look up the fresh class by name', () => {
const candidate = makeCandidate({ component: StubComponent, preserveOnHmr: true });

const captured = captureDialogsForHmr([candidate]);

expect(captured).toHaveLength(1);
expect(captured[0].componentName).toBe('StubComponent');
});

it('uses the most-recently-defined class name even when the captured class is renamed in source', () => {
const candidateA = makeCandidate({ component: StubComponent, preserveOnHmr: true });
const candidateB = makeCandidate({ component: OtherStubComponent, preserveOnHmr: true });

const captured = captureDialogsForHmr([candidateA, candidateB]);

expect(captured.map((c) => c.componentName)).toEqual(['StubComponent', 'OtherStubComponent']);
});

it('clears any prior stash when nothing is preservable so a stale capture cannot leak forward', () => {
const stale = makeCandidate({ component: StubComponent, preserveOnHmr: true });
captureDialogsForHmr([stale]);
expect(peekPendingHmrDialogs()).toHaveLength(1);

const preservedNothing = captureDialogsForHmr([
makeCandidate({ component: StubComponent, preserveOnHmr: false }),
]);

expect(preservedNothing).toEqual([]);
expect(peekPendingHmrDialogs()).toEqual([]);
});

it('grafts the captured afterClosed subject so the original consumer resolves on restoration', () => {
const subject = new Subject<unknown>();
const observed: unknown[] = [];
const completed: boolean[] = [];
subject.subscribe({
next: (value) => observed.push(value),
complete: () => completed.push(true),
});

const captured = captureDialogsForHmr([makeCandidate({ component: StubComponent, preserveOnHmr: true, subject })]);
captured[0].graftAfterClosed('closed-value');

expect(observed).toEqual(['closed-value']);
expect(completed).toEqual([true]);
});

it('graft is a no-op when the captured subject already completed', () => {
const subject = new Subject<unknown>();
subject.complete();

const captured = captureDialogsForHmr([makeCandidate({ component: StubComponent, preserveOnHmr: true, subject })]);

expect(() => captured[0].graftAfterClosed('ignored')).not.toThrow();
});
});

describe('consumePendingHmrDialogs', () => {
it('drains the stash so consecutive consumers do not see duplicates', () => {
captureDialogsForHmr([
makeCandidate({ component: StubComponent, preserveOnHmr: true }),
makeCandidate({ component: OtherStubComponent, preserveOnHmr: true }),
]);

expect(consumePendingHmrDialogs()).toHaveLength(2);
expect(consumePendingHmrDialogs()).toHaveLength(0);
});

it('returns an empty list when nothing has been stashed', () => {
expect(consumePendingHmrDialogs()).toEqual([]);
});
});

describe('abortCapturedDialog', () => {
it('completes the original subject so awaiting consumers do not dangle', () => {
const subject = new Subject<unknown>();
const completed: boolean[] = [];
subject.subscribe({ complete: () => completed.push(true) });

const captured = captureDialogsForHmr([makeCandidate({ component: StubComponent, preserveOnHmr: true, subject })]);
abortCapturedDialog(captured[0]);

expect(completed).toEqual([true]);
});
});
});
Loading
Loading