@absolutejs/absolute 0.19.0-beta.751 → 0.19.0-beta.753
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/angular/browser.js +20 -13
- package/dist/angular/browser.js.map +5 -4
- package/dist/angular/index.js +20 -13
- package/dist/angular/index.js.map +5 -4
- package/dist/dev/client/handlers/angular.ts +29 -314
- package/dist/dev/client/handlers/angularRuntime.ts +66 -0
- package/dist/src/angular/hmrPreserveCore.d.ts +34 -0
- package/dist/src/angular/preserveAcrossHmr.d.ts +0 -17
- package/package.json +1 -1
|
@@ -3,18 +3,27 @@ import type {} from '../../../types/globals';
|
|
|
3
3
|
DEV MODE ONLY — never active in production.
|
|
4
4
|
|
|
5
5
|
Strategy:
|
|
6
|
-
1. Capture component state
|
|
6
|
+
1. Capture component/service state via `preserveAcrossHmr` opt-ins
|
|
7
7
|
2. Use document.startViewTransition() — browser captures a screenshot
|
|
8
8
|
3. Destroy old app, recreate root element, import new module
|
|
9
9
|
4. bootstrapApplication() renders new content (behind the screenshot)
|
|
10
|
-
5.
|
|
11
|
-
|
|
10
|
+
5. New instances restore from cache via `preserveAcrossHmr` in their
|
|
11
|
+
constructors / ngOnInit (gated on rebootInProgress flag)
|
|
12
|
+
6. Wait for `applicationRef.whenStable()` so lazy-route components
|
|
13
|
+
have a chance to construct, then close the restoration window
|
|
14
|
+
7. View transition resolves — browser smoothly crossfades to new
|
|
15
|
+
content
|
|
12
16
|
|
|
13
17
|
document.startViewTransition() is the native browser API for page
|
|
14
|
-
transitions. It captures a screenshot before the callback, runs
|
|
15
|
-
callback (which can be async), and crossfades when the callback
|
|
16
|
-
The user never sees empty/default state — only the
|
|
18
|
+
transitions. It captures a screenshot before the callback, runs
|
|
19
|
+
the callback (which can be async), and crossfades when the callback
|
|
20
|
+
finishes. The user never sees empty/default state — only the
|
|
21
|
+
before and after. */
|
|
17
22
|
|
|
23
|
+
import {
|
|
24
|
+
captureTrackedInstanceStates,
|
|
25
|
+
endHmrReboot
|
|
26
|
+
} from '../../../angular/hmrPreserveCore';
|
|
18
27
|
import { ANGULAR_INIT_TIMEOUT_MS } from '../constants';
|
|
19
28
|
import {
|
|
20
29
|
saveFormState,
|
|
@@ -37,15 +46,6 @@ type HMRMessage = {
|
|
|
37
46
|
};
|
|
38
47
|
};
|
|
39
48
|
|
|
40
|
-
type NgApi = {
|
|
41
|
-
applyChanges?: (component: unknown) => void;
|
|
42
|
-
getComponent?: (element: Element) => unknown;
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
type AngularClientWindow = Window & {
|
|
46
|
-
ng?: NgApi;
|
|
47
|
-
};
|
|
48
|
-
|
|
49
49
|
type AngularHmrApi = {
|
|
50
50
|
applyUpdate: (id: string, newCtor: unknown) => boolean;
|
|
51
51
|
getRegistry?: () => Map<string, unknown>;
|
|
@@ -73,9 +73,6 @@ const isAngularComponentExport = (
|
|
|
73
73
|
return 'ɵcmp' in value && Boolean(value.ɵcmp);
|
|
74
74
|
};
|
|
75
75
|
|
|
76
|
-
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
|
77
|
-
Boolean(value) && typeof value === 'object';
|
|
78
|
-
|
|
79
76
|
const swapStylesheet = (
|
|
80
77
|
cssUrl: string,
|
|
81
78
|
cssBaseName: string,
|
|
@@ -102,169 +99,6 @@ const swapStylesheet = (
|
|
|
102
99
|
document.head.appendChild(newLink);
|
|
103
100
|
};
|
|
104
101
|
|
|
105
|
-
// ─── State Capture/Restore via ng.getComponent ──────────────
|
|
106
|
-
|
|
107
|
-
type StateSnapshot = {
|
|
108
|
-
selector: string;
|
|
109
|
-
index: number;
|
|
110
|
-
properties: Record<string, unknown>;
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
const readDomCounter = (
|
|
114
|
-
element: Element,
|
|
115
|
-
properties: Record<string, unknown>
|
|
116
|
-
) => {
|
|
117
|
-
element
|
|
118
|
-
.querySelectorAll('[class*="value"], [class*="count"]')
|
|
119
|
-
.forEach((stateEl) => {
|
|
120
|
-
const text = stateEl.textContent;
|
|
121
|
-
if (text === null || text.trim() === '') return;
|
|
122
|
-
const num = parseInt(text.trim(), 10);
|
|
123
|
-
if (!isNaN(num)) properties['__dom_counter'] = num;
|
|
124
|
-
});
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
const copyInstanceProperty = (
|
|
128
|
-
instance: Record<string, unknown>,
|
|
129
|
-
key: string,
|
|
130
|
-
properties: Record<string, unknown>
|
|
131
|
-
) => {
|
|
132
|
-
if (key.startsWith('ɵ') || key.startsWith('__')) return;
|
|
133
|
-
const val = instance[key];
|
|
134
|
-
if (typeof val === 'function') return;
|
|
135
|
-
properties[key] = val;
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
const captureInstanceProperties = (
|
|
139
|
-
ngApi: NgApi | undefined,
|
|
140
|
-
element: Element,
|
|
141
|
-
properties: Record<string, unknown>
|
|
142
|
-
) => {
|
|
143
|
-
if (!ngApi || typeof ngApi.getComponent !== 'function') return;
|
|
144
|
-
|
|
145
|
-
try {
|
|
146
|
-
const instance = ngApi.getComponent(element);
|
|
147
|
-
if (!isObjectRecord(instance)) return;
|
|
148
|
-
|
|
149
|
-
Object.keys(instance).forEach((key) => {
|
|
150
|
-
copyInstanceProperty(instance, key, properties);
|
|
151
|
-
});
|
|
152
|
-
} catch {
|
|
153
|
-
/* ignored */
|
|
154
|
-
}
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
const captureComponentState = () => {
|
|
158
|
-
const snapshots: StateSnapshot[] = [];
|
|
159
|
-
const selectorCounts = new Map<string, number>();
|
|
160
|
-
const angularWindow: AngularClientWindow = window;
|
|
161
|
-
const ngApi = angularWindow.ng;
|
|
162
|
-
|
|
163
|
-
document.querySelectorAll('*').forEach((elem) => {
|
|
164
|
-
const tagName = elem.tagName.toLowerCase();
|
|
165
|
-
if (!tagName.includes('-')) return;
|
|
166
|
-
|
|
167
|
-
const count = selectorCounts.get(tagName) || 0;
|
|
168
|
-
selectorCounts.set(tagName, count + 1);
|
|
169
|
-
|
|
170
|
-
const properties: Record<string, unknown> = {};
|
|
171
|
-
readDomCounter(elem, properties);
|
|
172
|
-
captureInstanceProperties(ngApi, elem, properties);
|
|
173
|
-
|
|
174
|
-
if (Object.keys(properties).length > 0) {
|
|
175
|
-
snapshots.push({ index: count, properties, selector: tagName });
|
|
176
|
-
}
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
return snapshots;
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
const safeSetProperty = (
|
|
183
|
-
instance: Record<string, unknown>,
|
|
184
|
-
key: string,
|
|
185
|
-
value: unknown
|
|
186
|
-
) => {
|
|
187
|
-
try {
|
|
188
|
-
instance[key] = value;
|
|
189
|
-
} catch {
|
|
190
|
-
/* ignored */
|
|
191
|
-
}
|
|
192
|
-
};
|
|
193
|
-
|
|
194
|
-
const restoreInstanceProperties = (
|
|
195
|
-
instance: Record<string, unknown>,
|
|
196
|
-
snap: StateSnapshot
|
|
197
|
-
) => {
|
|
198
|
-
const domCounter = snap.properties['__dom_counter'];
|
|
199
|
-
Object.entries(snap.properties).forEach(([key, value]) => {
|
|
200
|
-
if (key === '__dom_counter') return;
|
|
201
|
-
safeSetProperty(instance, key, value);
|
|
202
|
-
});
|
|
203
|
-
if (
|
|
204
|
-
domCounter !== undefined &&
|
|
205
|
-
typeof domCounter === 'number' &&
|
|
206
|
-
'count' in instance
|
|
207
|
-
) {
|
|
208
|
-
instance['count'] = domCounter;
|
|
209
|
-
}
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
const restoreViaInstance = (
|
|
213
|
-
ngApi: NgApi | undefined,
|
|
214
|
-
element: Element,
|
|
215
|
-
snap: StateSnapshot
|
|
216
|
-
) => {
|
|
217
|
-
if (!ngApi || typeof ngApi.getComponent !== 'function') return false;
|
|
218
|
-
|
|
219
|
-
try {
|
|
220
|
-
const instance = ngApi.getComponent(element);
|
|
221
|
-
if (!isObjectRecord(instance)) return false;
|
|
222
|
-
|
|
223
|
-
restoreInstanceProperties(instance, snap);
|
|
224
|
-
if (typeof ngApi.applyChanges === 'function')
|
|
225
|
-
ngApi.applyChanges(element);
|
|
226
|
-
|
|
227
|
-
return true;
|
|
228
|
-
} catch {
|
|
229
|
-
return false;
|
|
230
|
-
}
|
|
231
|
-
};
|
|
232
|
-
|
|
233
|
-
const restoreDomFallback = (element: Element, snap: StateSnapshot) => {
|
|
234
|
-
const domCounter = snap.properties['__dom_counter'];
|
|
235
|
-
if (domCounter === undefined) return;
|
|
236
|
-
|
|
237
|
-
element
|
|
238
|
-
.querySelectorAll('[class*="value"], [class*="count"]')
|
|
239
|
-
.forEach((counterEl) => {
|
|
240
|
-
counterEl.textContent = String(domCounter);
|
|
241
|
-
});
|
|
242
|
-
};
|
|
243
|
-
|
|
244
|
-
const restoreComponentState = (snapshots: StateSnapshot[]) => {
|
|
245
|
-
const angularWindow: AngularClientWindow = window;
|
|
246
|
-
const ngApi = angularWindow.ng;
|
|
247
|
-
if (snapshots.length === 0) return;
|
|
248
|
-
|
|
249
|
-
const bySelector = new Map<string, StateSnapshot[]>();
|
|
250
|
-
for (const snap of snapshots) {
|
|
251
|
-
const list = bySelector.get(snap.selector) || [];
|
|
252
|
-
list.push(snap);
|
|
253
|
-
bySelector.set(snap.selector, list);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
bySelector.forEach((snaps, selector) => {
|
|
257
|
-
const elements = document.querySelectorAll(selector);
|
|
258
|
-
snaps.forEach((snap) => {
|
|
259
|
-
const element = elements[snap.index];
|
|
260
|
-
if (!element) return;
|
|
261
|
-
|
|
262
|
-
const restored = restoreViaInstance(ngApi, element, snap);
|
|
263
|
-
if (!restored) restoreDomFallback(element, snap);
|
|
264
|
-
});
|
|
265
|
-
});
|
|
266
|
-
};
|
|
267
|
-
|
|
268
102
|
// ─── Wait for Angular bootstrap (event-based, no polling) ───
|
|
269
103
|
// Installs a property setter trap on window.__ANGULAR_APP__ that
|
|
270
104
|
// resolves the promise the instant the bootstrap code writes to it.
|
|
@@ -507,9 +341,11 @@ const processMessage = async (message: HMRMessage) => {
|
|
|
507
341
|
);
|
|
508
342
|
}
|
|
509
343
|
|
|
510
|
-
// Fast path didn't apply — full re-bootstrap
|
|
511
|
-
//
|
|
512
|
-
//
|
|
344
|
+
// Fast path didn't apply — full re-bootstrap. Components and services
|
|
345
|
+
// that opted into `preserveAcrossHmr(this)` keep their state; anything
|
|
346
|
+
// that didn't opt in is reset to its class-field defaults. The summary
|
|
347
|
+
// log emitted by `endHmrReboot` after the reboot tells the developer
|
|
348
|
+
// which classes were preserved.
|
|
513
349
|
await handleFullUpdate(message);
|
|
514
350
|
};
|
|
515
351
|
|
|
@@ -698,7 +534,10 @@ const runWithViewTransition = async (updateFn: () => Promise<void>) => {
|
|
|
698
534
|
};
|
|
699
535
|
|
|
700
536
|
const handleFullUpdate = async (message: HMRMessage) => {
|
|
701
|
-
|
|
537
|
+
// DOM-level state — preserved separately from instance state because
|
|
538
|
+
// it lives in the document, not in component fields. Form values and
|
|
539
|
+
// scroll position survive a full re-bootstrap regardless of whether
|
|
540
|
+
// any component opted into `preserveAcrossHmr`.
|
|
702
541
|
const scrollState = saveScrollState();
|
|
703
542
|
const formState = saveFormState();
|
|
704
543
|
|
|
@@ -724,8 +563,10 @@ const handleFullUpdate = async (message: HMRMessage) => {
|
|
|
724
563
|
// Snapshot every instance that opted into `preserveAcrossHmr(this)`
|
|
725
564
|
// before destroying the app, and flip the reboot-in-progress flag
|
|
726
565
|
// on. The new instances created during bootstrap will read cached
|
|
727
|
-
// state back via the same helper while the flag is on.
|
|
728
|
-
|
|
566
|
+
// state back via the same helper while the flag is on. Both this
|
|
567
|
+
// capture call and the user-facing `preserveAcrossHmr` helper
|
|
568
|
+
// share the same `globalThis`-anchored cache via `hmrPreserveCore`.
|
|
569
|
+
captureTrackedInstanceStates();
|
|
729
570
|
try {
|
|
730
571
|
destroyAngularApp();
|
|
731
572
|
await bootstrapAngularModule(
|
|
@@ -733,7 +574,6 @@ const handleFullUpdate = async (message: HMRMessage) => {
|
|
|
733
574
|
rootSelector,
|
|
734
575
|
rootContainer
|
|
735
576
|
);
|
|
736
|
-
restoreComponentState(componentState);
|
|
737
577
|
tickAngularApp();
|
|
738
578
|
restoreFormState(formState);
|
|
739
579
|
restoreScrollState(scrollState);
|
|
@@ -749,134 +589,9 @@ const handleFullUpdate = async (message: HMRMessage) => {
|
|
|
749
589
|
// loads, microtasks, scheduled CD) — strictly event-based,
|
|
750
590
|
// no fixed timer needed.
|
|
751
591
|
await waitForAppStable();
|
|
752
|
-
|
|
592
|
+
endHmrReboot();
|
|
753
593
|
}
|
|
754
594
|
};
|
|
755
595
|
|
|
756
596
|
await runWithViewTransition(doUpdate);
|
|
757
597
|
};
|
|
758
|
-
|
|
759
|
-
/* Snapshot every WeakRef-tracked instance's *preservable* own
|
|
760
|
-
properties into the shared cache. The runtime helper
|
|
761
|
-
`preserveAcrossHmr(this)` from `@absolutejs/absolute/angular`
|
|
762
|
-
populates the tracker, and on the constructor of the next-bootstrapped
|
|
763
|
-
instance reads back from the cache. We talk to the same `globalThis`-
|
|
764
|
-
anchored Map/Set as that helper rather than importing it directly so
|
|
765
|
-
this dev-client bundle doesn't pull in the Angular runtime.
|
|
766
|
-
|
|
767
|
-
Only primitives, plain `{}` objects, and arrays of those are
|
|
768
|
-
preserved. Class instances (HttpClient, BehaviorSubject, etc.) are
|
|
769
|
-
*not* preserved because the new instance must get those from its own
|
|
770
|
-
injector — restoring an OLD-injector reference onto a NEW instance
|
|
771
|
-
would corrupt the new app. */
|
|
772
|
-
type HmrPreserveScope = typeof globalThis & {
|
|
773
|
-
__ABS_HMR_INSTANCE_STATE__?: Map<string, Record<string, unknown>>;
|
|
774
|
-
__ABS_HMR_TRACKED_INSTANCES__?: Set<WeakRef<object>>;
|
|
775
|
-
__ABS_HMR_INSTANCE_KEYS__?: WeakMap<object, string>;
|
|
776
|
-
__ABS_HMR_REBOOT_IN_PROGRESS__?: { value: boolean };
|
|
777
|
-
__ABS_HMR_REBOOT_STATS__?: { captured: number; restoredKeys: Set<string> };
|
|
778
|
-
};
|
|
779
|
-
|
|
780
|
-
const isPreservableValue = (value: unknown, depth = 0): boolean => {
|
|
781
|
-
if (depth > 8) return false;
|
|
782
|
-
if (value === null || value === undefined) return true;
|
|
783
|
-
const t = typeof value;
|
|
784
|
-
if (t === 'string' || t === 'number' || t === 'boolean' || t === 'bigint')
|
|
785
|
-
return true;
|
|
786
|
-
if (t === 'function' || t === 'symbol') return false;
|
|
787
|
-
if (Array.isArray(value)) {
|
|
788
|
-
return value.every((item) => isPreservableValue(item, depth + 1));
|
|
789
|
-
}
|
|
790
|
-
if (t === 'object') {
|
|
791
|
-
const proto = Object.getPrototypeOf(value);
|
|
792
|
-
if (proto !== Object.prototype && proto !== null) return false;
|
|
793
|
-
|
|
794
|
-
return Object.values(value as object).every((v) =>
|
|
795
|
-
isPreservableValue(v, depth + 1)
|
|
796
|
-
);
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
return false;
|
|
800
|
-
};
|
|
801
|
-
|
|
802
|
-
const getHmrRebootStats = (scope: HmrPreserveScope) =>
|
|
803
|
-
(scope.__ABS_HMR_REBOOT_STATS__ ??= {
|
|
804
|
-
captured: 0,
|
|
805
|
-
restoredKeys: new Set<string>()
|
|
806
|
-
});
|
|
807
|
-
|
|
808
|
-
const captureHmrPreservedInstanceStates = () => {
|
|
809
|
-
const scope = globalThis as HmrPreserveScope;
|
|
810
|
-
const tracker = scope.__ABS_HMR_TRACKED_INSTANCES__;
|
|
811
|
-
|
|
812
|
-
const flag = (scope.__ABS_HMR_REBOOT_IN_PROGRESS__ ??= { value: false });
|
|
813
|
-
const stats = getHmrRebootStats(scope);
|
|
814
|
-
stats.captured = 0;
|
|
815
|
-
stats.restoredKeys.clear();
|
|
816
|
-
|
|
817
|
-
if (!tracker || tracker.size === 0) {
|
|
818
|
-
// Nothing tracked, but still flip the flag so any service/component
|
|
819
|
-
// that registers DURING the reboot (`preserveAcrossHmr` in
|
|
820
|
-
// constructor) knows it's in a reboot context — even though there's
|
|
821
|
-
// nothing to restore from.
|
|
822
|
-
flag.value = true;
|
|
823
|
-
|
|
824
|
-
return;
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
const cache = (scope.__ABS_HMR_INSTANCE_STATE__ ??= new Map());
|
|
828
|
-
const keyMap = scope.__ABS_HMR_INSTANCE_KEYS__;
|
|
829
|
-
const dead: WeakRef<object>[] = [];
|
|
830
|
-
const seen = new Set<string>();
|
|
831
|
-
|
|
832
|
-
cache.clear();
|
|
833
|
-
|
|
834
|
-
for (const ref of tracker) {
|
|
835
|
-
const instance = ref.deref();
|
|
836
|
-
if (!instance) {
|
|
837
|
-
dead.push(ref);
|
|
838
|
-
continue;
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
const className = instance.constructor?.name;
|
|
842
|
-
if (!className || className === 'Object') continue;
|
|
843
|
-
const fullKey = keyMap?.get(instance) ?? `${className}:`;
|
|
844
|
-
|
|
845
|
-
if (seen.has(fullKey)) {
|
|
846
|
-
console.warn(
|
|
847
|
-
`[HMR] preserveAcrossHmr collision on "${fullKey}". Two instances would use the same cache slot — the later one will overwrite the earlier one's state on full re-bootstrap. Pass a unique \`key\` argument (e.g. an @Input id) to differentiate.`
|
|
848
|
-
);
|
|
849
|
-
}
|
|
850
|
-
seen.add(fullKey);
|
|
851
|
-
|
|
852
|
-
const props: Record<string, unknown> = {};
|
|
853
|
-
for (const prop of Object.keys(instance)) {
|
|
854
|
-
const value = (instance as Record<string, unknown>)[prop];
|
|
855
|
-
if (isPreservableValue(value)) props[prop] = value;
|
|
856
|
-
}
|
|
857
|
-
cache.set(fullKey, props);
|
|
858
|
-
stats.captured++;
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
dead.forEach((ref) => tracker.delete(ref));
|
|
862
|
-
|
|
863
|
-
flag.value = true;
|
|
864
|
-
};
|
|
865
|
-
|
|
866
|
-
const endHmrPreservedReboot = () => {
|
|
867
|
-
const scope = globalThis as HmrPreserveScope;
|
|
868
|
-
const flag = scope.__ABS_HMR_REBOOT_IN_PROGRESS__;
|
|
869
|
-
if (flag) flag.value = false;
|
|
870
|
-
|
|
871
|
-
const stats = scope.__ABS_HMR_REBOOT_STATS__;
|
|
872
|
-
if (stats && stats.captured > 0) {
|
|
873
|
-
const restored = Array.from(stats.restoredKeys)
|
|
874
|
-
.map((k) => k.replace(/:$/, ''))
|
|
875
|
-
.sort();
|
|
876
|
-
console.info(
|
|
877
|
-
`[HMR] Full re-bootstrap: restored state for ${restored.length}/${stats.captured} tracked instance(s)${
|
|
878
|
-
restored.length > 0 ? ` — ${restored.join(', ')}` : ''
|
|
879
|
-
}. Components without preservation reset to defaults; opt in via \`preserveAcrossHmr(this)\`.`
|
|
880
|
-
);
|
|
881
|
-
}
|
|
882
|
-
};
|
|
@@ -25,6 +25,7 @@ import type {} from '../../../types/globals';
|
|
|
25
25
|
type AngularComponentDefinition = {
|
|
26
26
|
providers?: unknown;
|
|
27
27
|
providersResolver?: unknown;
|
|
28
|
+
selectors?: unknown[];
|
|
28
29
|
};
|
|
29
30
|
|
|
30
31
|
type ComponentCtor = (abstract new (...args: never[]) => unknown) & {
|
|
@@ -217,6 +218,55 @@ const patchConstructor = (entry: RegistryEntry, newCtor: ComponentCtor) => {
|
|
|
217
218
|
entry.registeredAt = Date.now();
|
|
218
219
|
};
|
|
219
220
|
|
|
221
|
+
/* The fast-patch swap of `ɵcmp` and prototype methods doesn't mark
|
|
222
|
+
live OnPush components as dirty — `applicationRef.tick()` alone
|
|
223
|
+
only checks views that are already marked dirty. So a template
|
|
224
|
+
edit on an OnPush component would silently fail to render until
|
|
225
|
+
the user clicked something that triggered a markForCheck.
|
|
226
|
+
We collect every successfully-patched ctor here, then `refresh()`
|
|
227
|
+
walks the DOM for each ctor's selector, gets the live instance via
|
|
228
|
+
the `ng` debug API, and calls `applyChanges` on it (which marks
|
|
229
|
+
the view dirty AND runs CD on its subtree). */
|
|
230
|
+
const pendingFastPatchRefresh: Set<ComponentCtor> = new Set();
|
|
231
|
+
|
|
232
|
+
type AngularDebugWindow = Window & {
|
|
233
|
+
ng?: {
|
|
234
|
+
applyChanges?: (component: unknown) => void;
|
|
235
|
+
getComponent?: (element: Element) => unknown;
|
|
236
|
+
};
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const componentTagSelectors = (ctor: ComponentCtor): string[] => {
|
|
240
|
+
const selectors = ctor.ɵcmp?.selectors;
|
|
241
|
+
if (!Array.isArray(selectors)) return [];
|
|
242
|
+
const tags: string[] = [];
|
|
243
|
+
for (const tuple of selectors) {
|
|
244
|
+
if (!Array.isArray(tuple)) continue;
|
|
245
|
+
const head = tuple[0];
|
|
246
|
+
// Component selectors lead with the tag name (a hyphenated
|
|
247
|
+
// element name); attribute selectors lead with `''`. Skip the
|
|
248
|
+
// attribute case — those are directives, not OnPush views.
|
|
249
|
+
if (typeof head === 'string' && head.includes('-')) tags.push(head);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return tags;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const markPatchedDirty = (ctor: ComponentCtor) => {
|
|
256
|
+
const ng = (window as AngularDebugWindow).ng;
|
|
257
|
+
if (!ng?.getComponent || !ng?.applyChanges) return;
|
|
258
|
+
for (const tag of componentTagSelectors(ctor)) {
|
|
259
|
+
document.querySelectorAll(tag).forEach((el) => {
|
|
260
|
+
try {
|
|
261
|
+
const instance = ng.getComponent?.(el);
|
|
262
|
+
if (instance) ng.applyChanges?.(instance);
|
|
263
|
+
} catch {
|
|
264
|
+
/* dev-only debug API — ignore failures */
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
220
270
|
const applyUpdate = (id: string, newCtor: unknown) => {
|
|
221
271
|
if (!isComponentCtor(newCtor)) return false;
|
|
222
272
|
|
|
@@ -251,6 +301,13 @@ const applyUpdate = (id: string, newCtor: unknown) => {
|
|
|
251
301
|
|
|
252
302
|
try {
|
|
253
303
|
patchConstructor(entry, newCtor);
|
|
304
|
+
// Queue this ctor for `refresh()` to mark its live instances
|
|
305
|
+
// dirty — the patch swapped metadata in place, but OnPush
|
|
306
|
+
// components need an explicit markForCheck to re-render.
|
|
307
|
+
// `liveCtor` is the on-page constructor (we patched into it);
|
|
308
|
+
// we use that for selector lookup since the swap may have
|
|
309
|
+
// updated `ɵcmp` on `liveCtor` itself.
|
|
310
|
+
pendingFastPatchRefresh.add(liveCtor);
|
|
254
311
|
|
|
255
312
|
return true;
|
|
256
313
|
} catch (err) {
|
|
@@ -262,6 +319,15 @@ const applyUpdate = (id: string, newCtor: unknown) => {
|
|
|
262
319
|
|
|
263
320
|
const refresh = () => {
|
|
264
321
|
if (!window.__ANGULAR_APP__) return;
|
|
322
|
+
// Mark every live instance of every patched component dirty before
|
|
323
|
+
// ticking. `tick()` alone wouldn't re-render OnPush components,
|
|
324
|
+
// since they only re-check on `markForCheck`. `applyChanges` marks
|
|
325
|
+
// the view dirty and runs CD on its subtree — covers both OnPush
|
|
326
|
+
// and Default change-detection components correctly.
|
|
327
|
+
for (const ctor of pendingFastPatchRefresh) {
|
|
328
|
+
markPatchedDirty(ctor);
|
|
329
|
+
}
|
|
330
|
+
pendingFastPatchRefresh.clear();
|
|
265
331
|
try {
|
|
266
332
|
window.__ANGULAR_APP__.tick();
|
|
267
333
|
} catch (err) {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type StateCache = Map<string, Record<string, unknown>>;
|
|
2
|
+
export type InstanceTracker = Set<WeakRef<object>>;
|
|
3
|
+
export type InstanceKeyMap = WeakMap<object, string>;
|
|
4
|
+
export type RebootFlag = {
|
|
5
|
+
value: boolean;
|
|
6
|
+
};
|
|
7
|
+
export type RebootStats = {
|
|
8
|
+
captured: number;
|
|
9
|
+
restoredKeys: Set<string>;
|
|
10
|
+
};
|
|
11
|
+
export declare const isHmrPreserveDev: () => boolean;
|
|
12
|
+
export declare const getCache: () => StateCache;
|
|
13
|
+
export declare const getTracker: () => InstanceTracker;
|
|
14
|
+
export declare const getKeyMap: () => InstanceKeyMap;
|
|
15
|
+
export declare const getRebootFlag: () => RebootFlag;
|
|
16
|
+
export declare const getRebootStats: () => RebootStats;
|
|
17
|
+
export declare const isPreservable: (value: unknown, depth?: number) => boolean;
|
|
18
|
+
export declare const buildCacheKey: (instance: object, key?: unknown) => string | null;
|
|
19
|
+
/** Copy preservable own properties from the cached snapshot onto the
|
|
20
|
+
* instance. Records the restoration in stats so the end-of-reboot
|
|
21
|
+
* summary can list which classes had state restored. Returns whether
|
|
22
|
+
* anything was actually written. */
|
|
23
|
+
export declare const restoreFromCacheCore: (instance: object, key: string) => boolean;
|
|
24
|
+
/** Snapshot every tracked instance's preservable own properties into
|
|
25
|
+
* the shared cache and flip the reboot-in-progress flag on. Called by
|
|
26
|
+
* the dev-client HMR handler right before `destroyAngularApp()`. */
|
|
27
|
+
export declare const captureTrackedInstanceStates: () => void;
|
|
28
|
+
/** Clear the active-reboot flag and emit a one-line summary so
|
|
29
|
+
* developers can see at-a-glance which classes had state preserved.
|
|
30
|
+
* Called by the dev-client HMR handler after the new app has reported
|
|
31
|
+
* stable. After this, `preserveAcrossHmr` calls track but don't
|
|
32
|
+
* restore — so navigating to a route after HMR doesn't resurrect
|
|
33
|
+
* stale state from the last reboot. */
|
|
34
|
+
export declare const endHmrReboot: () => void;
|
|
@@ -10,20 +10,3 @@
|
|
|
10
10
|
* on `@Input` values, since Angular sets inputs between
|
|
11
11
|
* constructor and ngOnInit. */
|
|
12
12
|
export declare const preserveAcrossHmr: (instance: object, key?: string | number) => void;
|
|
13
|
-
/** Snapshot every tracked instance's preservable own properties into
|
|
14
|
-
* the cache and flip the reboot flag on. Called by the HMR client
|
|
15
|
-
* right before `destroyAngularApp()`. Properties that aren't
|
|
16
|
-
* preservable (Angular-injected services, Subjects, class instances)
|
|
17
|
-
* are skipped — the new instance gets fresh ones from its new
|
|
18
|
-
* injector, just like at first bootstrap. */
|
|
19
|
-
export declare const captureTrackedInstanceStates: () => void;
|
|
20
|
-
/** Clear the active-reboot flag and emit a one-line summary so
|
|
21
|
-
* developers can see at-a-glance which classes had state preserved.
|
|
22
|
-
* Helps surface the existence of `preserveAcrossHmr` to anyone whose
|
|
23
|
-
* state was reset because they hadn't opted in.
|
|
24
|
-
* Called by the HMR client after the new app has finished
|
|
25
|
-
* bootstrapping (success or failure — wrap in `try/finally`). After
|
|
26
|
-
* this, `preserveAcrossHmr` calls will track but won't restore — so
|
|
27
|
-
* navigating to a route after HMR doesn't resurrect stale state from
|
|
28
|
-
* the last reboot. */
|
|
29
|
-
export declare const endHmrReboot: () => void;
|
package/package.json
CHANGED