@absolutejs/absolute 0.19.0-beta.752 → 0.19.0-beta.754

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.
@@ -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 (ng.getComponent) + DOM 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. After bootstrap: restore state via ng.getComponent + ng.applyChanges
11
- 6. View transition resolves browser smoothly crossfades to new content
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 the
15
- callback (which can be async), and crossfades when the callback finishes.
16
- The user never sees empty/default state — only the before and after. */
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 (loses in-memory app state
511
- // like auth tokens; only happens when the fast path can't handle the
512
- // change, e.g. routes/providers/services or a never-seen component).
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
- const componentState = captureComponentState();
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
- captureHmrPreservedInstanceStates();
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,139 +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
- endHmrPreservedReboot();
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 seen = new Set<string>();
830
-
831
- cache.clear();
832
-
833
- for (const ref of tracker) {
834
- const instance = ref.deref();
835
- // Skip already-GC'd refs. We don't bother bookkeeping a "dead"
836
- // list because the entire tracker is cleared after this loop.
837
- if (!instance) continue;
838
-
839
- const className = instance.constructor?.name;
840
- if (!className || className === 'Object') continue;
841
- const fullKey = keyMap?.get(instance) ?? `${className}:`;
842
-
843
- if (seen.has(fullKey)) {
844
- console.warn(
845
- `[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.`
846
- );
847
- }
848
- seen.add(fullKey);
849
-
850
- const props: Record<string, unknown> = {};
851
- for (const prop of Object.keys(instance)) {
852
- const value = (instance as Record<string, unknown>)[prop];
853
- if (isPreservableValue(value)) props[prop] = value;
854
- }
855
- cache.set(fullKey, props);
856
- stats.captured++;
857
- }
858
-
859
- // Every instance just captured is about to die: `destroyAngularApp()`
860
- // runs immediately after this. New instances from the next bootstrap
861
- // repopulate the tracker via their own `preserveAcrossHmr(this)`
862
- // calls. If we left existing WeakRefs in place, the JS engine often
863
- // won't have GC'd the old objects yet at the next capture — those
864
- // zombies would inflate the captured count and trigger spurious
865
- // collision warnings against the new generation's instances.
866
- tracker.clear();
867
-
868
- flag.value = true;
869
- };
870
-
871
- const endHmrPreservedReboot = () => {
872
- const scope = globalThis as HmrPreserveScope;
873
- const flag = scope.__ABS_HMR_REBOOT_IN_PROGRESS__;
874
- if (flag) flag.value = false;
875
-
876
- const stats = scope.__ABS_HMR_REBOOT_STATS__;
877
- if (stats && stats.captured > 0) {
878
- const restored = Array.from(stats.restoredKeys)
879
- .map((k) => k.replace(/:$/, ''))
880
- .sort();
881
- console.info(
882
- `[HMR] Full re-bootstrap: restored state for ${restored.length}/${stats.captured} tracked instance(s)${
883
- restored.length > 0 ? ` — ${restored.join(', ')}` : ''
884
- }. Components without preservation reset to defaults; opt in via \`preserveAcrossHmr(this)\`.`
885
- );
886
- }
887
- };
@@ -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
@@ -349,5 +349,5 @@
349
349
  ]
350
350
  }
351
351
  },
352
- "version": "0.19.0-beta.752"
352
+ "version": "0.19.0-beta.754"
353
353
  }