Skip to content

Commit 6b2e4b9

Browse files
committed
feat: hmr state with modals and router
1 parent 17173d2 commit 6b2e4b9

8 files changed

Lines changed: 648 additions & 58 deletions

File tree

packages/angular/src/lib/application.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,61 @@ export function runNativeScriptAngularApp<T, K>(options: AppRunOptions<T, K>) {
749749
disposeLastModules('hotreload');
750750
disposePlatform('hotreload');
751751
};
752+
// Pre-import hook for HMR runtimes. Must be called BEFORE the changed
753+
// component modules are re-imported, otherwise their ɵɵdefineComponent
754+
// calls fire against the OLD `GENERATED_COMP_IDS` map and Angular emits
755+
// a benign-but-noisy NG0912 "Component ID generation collision" warning
756+
// for every component the user has touched. Calling
757+
// `ɵresetCompiledComponents` here clears the map (and the related
758+
// ownerNgModule / verifiedNgModule WeakMaps) so the fresh defs register
759+
// into an empty table.
760+
//
761+
// The post-reboot call inside `bootstrapRoot('hotreload')` remains in
762+
// place as a safety net: a project that doesn't wire its HMR runtime to
763+
// this hook still gets the reset (just one cycle late, after the warning
764+
// has already surfaced).
765+
global['__reset_ng_compiled_components__'] = () => {
766+
resetAngularHmrCompiledComponents(getAngularCoreForHmrReset(AngularCore as any, globalThis as any));
767+
};
768+
769+
// Suppress benign HMR-induced NG0912 ("Component ID generation collision")
770+
// warnings. On a `.ts` edit Angular Live Reload's
771+
// `ListenNowComponent_UpdateMetadata` function in the freshly re-imported
772+
// module calls `ɵɵreplaceMetadata` → `ɵɵdefineComponent` → `getComponentId`
773+
// against a class that shares its name with the just-rebooted instance but
774+
// not its identity (one comes from the route-loaded module, the other from
775+
// the dynamically-fetched `/@ng/component?c=…` metadata chunk). The check
776+
// surfaces every component the user touches as a noisy warning even though
777+
// there's no real collision — same logical class, two transient identities
778+
// during the HMR cycle.
779+
//
780+
// Real collisions — different classes that happen to hash to the same id —
781+
// produce a warning where `'X' and 'Y'` (different class names) appear in
782+
// the message. We only filter when both names match, so genuine duplicates
783+
// still reach the user.
784+
//
785+
// Install once per process; the filter self-detaches if the warning text
786+
// changes shape (defensive against future Angular wording tweaks).
787+
(() => {
788+
const w: { __NS_ANGULAR_NG0912_FILTER_INSTALLED__?: boolean } = global as any;
789+
if (w.__NS_ANGULAR_NG0912_FILTER_INSTALLED__) return;
790+
w.__NS_ANGULAR_NG0912_FILTER_INSTALLED__ = true;
791+
const origWarn = console.warn.bind(console);
792+
// Pattern: "Components 'Foo' and 'Bar' with selector 'xyz'" — capture
793+
// both class names and compare. We suppress only when they're identical
794+
// (the HMR pseudo-collision signature).
795+
const NG0912_NAME_MATCH = /NG0912[\s\S]*?Components '([^']+)' and '([^']+)' with selector/;
796+
console.warn = (...args: any[]) => {
797+
const msg = String(args[0] ?? '');
798+
if (msg.includes('NG0912')) {
799+
const m = NG0912_NAME_MATCH.exec(msg);
800+
if (m && m[1] === m[2]) {
801+
return;
802+
}
803+
}
804+
origWarn(...args);
805+
};
806+
})();
752807
global['__reboot_ng_modules__'] = (shouldDisposePlatform: boolean = false) => {
753808
// Bump the global HMR cycle counter so subsequent diagnostic log
754809
// lines (class registry, dialog services) can be cross-referenced
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
import { AddViewHost, installPvcModalHostPropPropagation, ModalHostView, propagateModalHostPropsToDescendants, PVC_ADD_VIEW_WRAPPED_MARKER } from './modal-host-props';
2+
3+
/**
4+
* Minimal stand-in for a NativeScript `View` shape that supports
5+
* the `eachChildView` walk these helpers rely on. Real `View`
6+
* instances satisfy `ModalHostView` trivially; using a plain JS
7+
* stub here keeps the spec free of `@nativescript/core` (which
8+
* cannot load in the Jest Node runner without a runtime stub).
9+
*/
10+
class FakeView implements ModalHostView, AddViewHost {
11+
_dialogFragment?: unknown;
12+
viewController?: unknown;
13+
children: FakeView[] = [];
14+
15+
constructor(public name: string = 'view') {}
16+
17+
eachChildView(callback: (child: ModalHostView) => boolean): void {
18+
for (const child of this.children) {
19+
if (callback(child) === false) {
20+
return;
21+
}
22+
}
23+
}
24+
25+
// ProxyViewContainer-shaped add: links child into `children` and is
26+
// the call site we wrap in `installPvcModalHostPropPropagation`.
27+
_addView(view: ModalHostView, _atIndex?: number): void {
28+
this.children.push(view as FakeView);
29+
}
30+
}
31+
32+
function buildSubtree(...names: string[]): FakeView[] {
33+
return names.map((n) => new FakeView(n));
34+
}
35+
36+
describe('modal-host-props', () => {
37+
describe('propagateModalHostPropsToDescendants', () => {
38+
it('mirrors `_dialogFragment` and `viewController` from the wrapper onto every native-like descendant', () => {
39+
// Wrapper holds the canonical references NS core stamped on
40+
// it during `_showNativeModalView`. The PVC, the template
41+
// root, and any nested child must end up with the same
42+
// references so user template code (`onLoaded($event)` →
43+
// `args.object._dialogFragment.getDialog()`) reads the real
44+
// host objects instead of `undefined`.
45+
const dialogFragment = { kind: 'DialogFragment' };
46+
const viewController = { kind: 'UIViewController' };
47+
const wrapper = new FakeView('wrapper');
48+
wrapper._dialogFragment = dialogFragment;
49+
wrapper.viewController = viewController;
50+
51+
const pvc = new FakeView('pvc');
52+
const stackLayout = new FakeView('stack');
53+
const label = new FakeView('label');
54+
wrapper.children = [pvc];
55+
pvc.children = [stackLayout];
56+
stackLayout.children = [label];
57+
58+
propagateModalHostPropsToDescendants(wrapper, wrapper);
59+
60+
expect(pvc._dialogFragment).toBe(dialogFragment);
61+
expect(pvc.viewController).toBe(viewController);
62+
expect(stackLayout._dialogFragment).toBe(dialogFragment);
63+
expect(stackLayout.viewController).toBe(viewController);
64+
expect(label._dialogFragment).toBe(dialogFragment);
65+
expect(label.viewController).toBe(viewController);
66+
});
67+
68+
it('never overwrites the wrapper itself even when the walk starts at the wrapper', () => {
69+
// NS core owns the wrapper's host-prop assignment; mirroring
70+
// would only let our copy drift out of sync (e.g. across an
71+
// HMR re-render where NS reassigns on the wrapper but our
72+
// mirror lags behind).
73+
const dialogFragment = { kind: 'DialogFragment' };
74+
const wrapper = new FakeView('wrapper');
75+
wrapper._dialogFragment = dialogFragment;
76+
const sentinel = wrapper._dialogFragment;
77+
78+
propagateModalHostPropsToDescendants(wrapper, wrapper);
79+
80+
// Identity preserved: we did not even touch the slot.
81+
expect(wrapper._dialogFragment).toBe(sentinel);
82+
});
83+
84+
it('no-ops when the modal has not been shown yet (wrapper has neither host prop)', () => {
85+
// Before `showModal` returns, NS core has not stamped the
86+
// host props. The helper must not write `undefined` onto the
87+
// descendants — that would shadow a real value if mirroring
88+
// ever races a later call.
89+
const wrapper = new FakeView('wrapper');
90+
const stack = new FakeView('stack');
91+
wrapper.children = [stack];
92+
stack._dialogFragment = { kind: 'preexisting' };
93+
94+
propagateModalHostPropsToDescendants(wrapper, wrapper);
95+
96+
expect(stack._dialogFragment).toEqual({ kind: 'preexisting' });
97+
});
98+
99+
it('no-ops when the modal has already closed (NS sets both props to null on the wrapper)', () => {
100+
// NS clears `_dialogFragment` / `viewController` to `null`
101+
// on close. Mirroring after that would persist stale
102+
// references on descendants past their useful lifetime —
103+
// worse, it would *clear* user-set values in the same slot.
104+
const wrapper = new FakeView('wrapper');
105+
wrapper._dialogFragment = null;
106+
wrapper.viewController = null;
107+
const stack = new FakeView('stack');
108+
const userStash = { kind: 'user-stashed' };
109+
stack._dialogFragment = userStash;
110+
wrapper.children = [stack];
111+
112+
propagateModalHostPropsToDescendants(wrapper, wrapper);
113+
114+
expect(stack._dialogFragment).toBe(userStash);
115+
});
116+
117+
it('skips redundant writes when the descendant already holds the same reference (idempotency)', () => {
118+
// Repeat calls are expected during HMR: the initial-open
119+
// propagate runs, then the PVC `_addView` wrap re-runs the
120+
// walk for each child added during a re-render. Re-assigning
121+
// the same identity is harmless but Object.defineProperty
122+
// tricks (some host views have setter side effects on
123+
// assignment) would fire spurious updates.
124+
const dialogFragment = { kind: 'DialogFragment' };
125+
const wrapper = new FakeView('wrapper');
126+
wrapper._dialogFragment = dialogFragment;
127+
const stack = new FakeView('stack');
128+
stack._dialogFragment = dialogFragment;
129+
wrapper.children = [stack];
130+
let setterCalls = 0;
131+
Object.defineProperty(stack, '_dialogFragment', {
132+
get: () => dialogFragment,
133+
set: () => {
134+
setterCalls++;
135+
},
136+
});
137+
138+
propagateModalHostPropsToDescendants(wrapper, wrapper);
139+
140+
expect(setterCalls).toBe(0);
141+
});
142+
143+
it('walks `root` directly when called with a non-wrapper root (the HMR `_addView` pre-hook entry point)', () => {
144+
// The PVC `_addView` wrap calls this with `root === viewBeingAdded`.
145+
// We must propagate onto the just-added subtree even though
146+
// it is not yet a child of the wrapper.
147+
const dialogFragment = { kind: 'DialogFragment' };
148+
const wrapper = new FakeView('wrapper');
149+
wrapper._dialogFragment = dialogFragment;
150+
const newTemplateRoot = new FakeView('new-template-root');
151+
const nested = new FakeView('nested');
152+
newTemplateRoot.children = [nested];
153+
154+
propagateModalHostPropsToDescendants(wrapper, newTemplateRoot);
155+
156+
expect(newTemplateRoot._dialogFragment).toBe(dialogFragment);
157+
expect(nested._dialogFragment).toBe(dialogFragment);
158+
});
159+
160+
it('is safe with nullish inputs', () => {
161+
// Defensive: helpers are called from real Angular tear-down
162+
// paths where either input can briefly be `undefined`.
163+
expect(() => propagateModalHostPropsToDescendants(undefined, undefined)).not.toThrow();
164+
expect(() => propagateModalHostPropsToDescendants(undefined, new FakeView())).not.toThrow();
165+
expect(() => propagateModalHostPropsToDescendants(new FakeView(), undefined)).not.toThrow();
166+
});
167+
});
168+
169+
describe('installPvcModalHostPropPropagation', () => {
170+
it('mirrors host-props onto each child added after install (HMR `ɵɵreplaceMetadata` rerender simulation)', () => {
171+
// Setup: wrapper already has host props (modal is open).
172+
// We install on the host PVC, then simulate Angular's HMR
173+
// re-render path: add a fresh template root via the wrapped
174+
// `_addView`. The new child *must* have `_dialogFragment`
175+
// set *before* the underlying `_addView` runs, because NS's
176+
// real `_addView` synchronously fires the `loaded` event
177+
// chain on the new view — that's exactly where the user's
178+
// `onLoaded` handler crashed.
179+
const dialogFragment = { kind: 'DialogFragment' };
180+
const wrapper = new FakeView('wrapper');
181+
wrapper._dialogFragment = dialogFragment;
182+
183+
// Install the "original" `_addView` spy FIRST so our wrap
184+
// captures it via `bind()` and calls it as the inner.
185+
// Reversing the order would let the test's spy clobber the
186+
// wrap and assert nothing meaningful.
187+
const pvc = new FakeView('pvc');
188+
let dialogFragmentAtAddTime: unknown = 'not-set';
189+
const baseAdd = pvc._addView!.bind(pvc);
190+
pvc._addView = (view: ModalHostView, atIndex?: number) => {
191+
dialogFragmentAtAddTime = (view as FakeView)._dialogFragment;
192+
baseAdd(view, atIndex);
193+
};
194+
195+
installPvcModalHostPropPropagation(pvc, wrapper);
196+
197+
const newTemplateRoot = new FakeView('new-template-root');
198+
pvc._addView!(newTemplateRoot);
199+
200+
expect(dialogFragmentAtAddTime).toBe(dialogFragment);
201+
expect(newTemplateRoot._dialogFragment).toBe(dialogFragment);
202+
});
203+
204+
it('marks the host with `PVC_ADD_VIEW_WRAPPED_MARKER` and is idempotent on repeat install', () => {
205+
// Re-wrapping would create an O(n) chain of wrappers and
206+
// make the failure mode at close ("slow no-op") progressively
207+
// slower. The marker is the only signal we have to avoid this
208+
// since the host instance has no public install-state API.
209+
const wrapper = new FakeView('wrapper');
210+
wrapper._dialogFragment = { kind: 'DialogFragment' };
211+
const pvc = new FakeView('pvc');
212+
const original = pvc._addView;
213+
214+
installPvcModalHostPropPropagation(pvc, wrapper);
215+
const onceWrapped = pvc._addView;
216+
installPvcModalHostPropPropagation(pvc, wrapper);
217+
const twiceAttempted = pvc._addView;
218+
219+
expect(onceWrapped).not.toBe(original);
220+
expect(twiceAttempted).toBe(onceWrapped);
221+
expect((pvc as unknown as Record<string, unknown>)[PVC_ADD_VIEW_WRAPPED_MARKER]).toBe(true);
222+
});
223+
224+
it('preserves `_addView` return value and atIndex semantics so NS internals are not affected', () => {
225+
// ViewBase._addView is decorated with @profile and has no
226+
// return value, but ProxyViewContainer's overrides may, and
227+
// we must thread arguments through verbatim so behavior is
228+
// identical to the un-wrapped instance.
229+
const wrapper = new FakeView('wrapper');
230+
wrapper._dialogFragment = { kind: 'DialogFragment' };
231+
const pvc = new FakeView('pvc');
232+
let observedAtIndex: number | undefined = -1;
233+
pvc._addView = ((view: ModalHostView, atIndex?: number) => {
234+
observedAtIndex = atIndex;
235+
return `added:${(view as FakeView).name}` as unknown as void;
236+
}) as AddViewHost['_addView'];
237+
238+
installPvcModalHostPropPropagation(pvc, wrapper);
239+
240+
const newChild = new FakeView('new');
241+
const result = pvc._addView!(newChild, 7);
242+
243+
expect(observedAtIndex).toBe(7);
244+
expect(result).toBe('added:new');
245+
});
246+
247+
it('no-ops gracefully when the host has no `_addView` (e.g. an element that is not a View)', () => {
248+
// Defensive: `componentRef.location.nativeElement` is *almost
249+
// always* a `ProxyViewContainer`, but a third-party portal
250+
// outlet could plug in a plain object. The wrap must not
251+
// explode in that case — leaving the host untouched is the
252+
// right behavior because HMR re-render of a non-View host is
253+
// not a real scenario anyway.
254+
const wrapper = new FakeView('wrapper');
255+
wrapper._dialogFragment = { kind: 'DialogFragment' };
256+
const nonViewHost = { _dialogFragment: undefined } as unknown as AddViewHost;
257+
258+
expect(() => installPvcModalHostPropPropagation(nonViewHost, wrapper)).not.toThrow();
259+
expect((nonViewHost as unknown as Record<string, unknown>)[PVC_ADD_VIEW_WRAPPED_MARKER]).toBeUndefined();
260+
});
261+
262+
it('is safe with nullish inputs', () => {
263+
expect(() => installPvcModalHostPropPropagation(undefined, undefined)).not.toThrow();
264+
expect(() => installPvcModalHostPropPropagation(new FakeView(), undefined)).not.toThrow();
265+
expect(() => installPvcModalHostPropPropagation(undefined, new FakeView())).not.toThrow();
266+
});
267+
268+
it('re-reads wrapper host props on each `_addView` call so a re-render after the wrapper was re-stamped sees fresh values', () => {
269+
// Simulates: dialog opens → wrapper._dialogFragment = A,
270+
// wrap installed. Later, NS internals re-stamp the wrapper
271+
// (e.g. DialogFragment was recreated after app suspend — see
272+
// `_showNativeModalView`'s "Set owner._dialogFragment to
273+
// this in case the DialogFragment was recreated after app
274+
// suspend" branch). The wrap must mirror the *current*
275+
// wrapper value at re-render time, not the stale A.
276+
const wrapper = new FakeView('wrapper');
277+
wrapper._dialogFragment = { kind: 'A' };
278+
const pvc = new FakeView('pvc');
279+
installPvcModalHostPropPropagation(pvc, wrapper);
280+
281+
// Wrapper restamp simulating the post-suspend reassign.
282+
const fresh = { kind: 'B' };
283+
wrapper._dialogFragment = fresh;
284+
285+
const newChild = new FakeView('new');
286+
pvc._addView!(newChild);
287+
288+
expect(newChild._dialogFragment).toBe(fresh);
289+
});
290+
});
291+
});

0 commit comments

Comments
 (0)