Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
278aeec
feat: support Vite HMR
NathanWalker Dec 27, 2025
dbe2fb9
Merge remote-tracking branch 'origin/main' into feat/vite-hmr
NathanWalker Jan 4, 2026
62613cb
chore: merge main
NathanWalker Jan 4, 2026
277140f
feat: vite hmr wip
NathanWalker Mar 30, 2026
6b34841
chore: re-add zone files
NathanWalker Apr 11, 2026
c98184e
feat(angular): improve vite hmr runtime wiring
NathanWalker Apr 14, 2026
6628560
feat: ensure css applied on hmr http booted realm
NathanWalker Apr 16, 2026
5b56d73
feat: hmr with compiled components and route preservation
NathanWalker Apr 16, 2026
8e8c6c1
feat: hmr applying while keeping route state preserved
NathanWalker Apr 19, 2026
e1ec30d
feat: improve hmr conditions around routing
NathanWalker Apr 20, 2026
228eb3d
feat: hmr logs gated by hmrTraceCategory
NathanWalker Apr 25, 2026
3b61fcf
chore: 21.0.1-alpha.2
NathanWalker Apr 26, 2026
ee9ba3d
feat: hmr handling with modals and route preservation
NathanWalker Apr 26, 2026
2797943
chore: cleanup logs
NathanWalker Apr 26, 2026
4644128
chore: 21.0.1-alpha.3
NathanWalker Apr 26, 2026
22da079
feat: hmr cache service along with modal dialog handling
NathanWalker May 8, 2026
95822ce
chore: 21.0.1-alpha.4
NathanWalker May 8, 2026
cef939e
fix: improve route handling on hmr updates
NathanWalker May 9, 2026
0180b16
chore: 21.0.1-alpha.5
NathanWalker May 9, 2026
2b5753f
fix: backwards compat with webpack builds
NathanWalker May 11, 2026
d9d49dc
chore: 21.0.1-alpha.6
NathanWalker May 11, 2026
17173d2
fix: set tagName on NgView for Angular 21 bootstrap compatibility
NathanWalker May 24, 2026
6b2e4b9
feat: hmr state with modals and router
NathanWalker May 24, 2026
951754b
chore: 21.0.1-alpha.9
NathanWalker May 24, 2026
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.9",
"homepage": "https://nativescript.org/",
"repository": {
"type": "git",
Expand Down
474 changes: 422 additions & 52 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