@absolutejs/absolute 0.19.0-beta.750 → 0.19.0-beta.751

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.
@@ -606,6 +606,34 @@ const tickAngularApp = () => {
606
606
  }
607
607
  };
608
608
 
609
+ /* Resolve when Angular reports the application is stable: no pending
610
+ microtasks, scheduled CD, or in-flight lazy chunk loads. Used to gate
611
+ the close of the HMR restoration window so lazy-route components get
612
+ a chance to construct (and call `preserveAcrossHmr`) before
613
+ `rebootInProgress` flips back to false. Falls back after a generous
614
+ ceiling in the unlikely case `whenStable` never resolves (e.g. an
615
+ infinite retry on a service the new app never finishes initializing) —
616
+ we'd rather close the window than leave HMR wedged forever. */
617
+ const APP_STABLE_FALLBACK_MS = 10_000;
618
+
619
+ const waitForAppStable = async () => {
620
+ const app = window.__ANGULAR_APP__;
621
+ if (!app || typeof app.whenStable !== 'function') return;
622
+
623
+ let timer: ReturnType<typeof setTimeout> | undefined;
624
+ const fallback = new Promise<void>((resolve) => {
625
+ timer = setTimeout(resolve, APP_STABLE_FALLBACK_MS);
626
+ });
627
+
628
+ try {
629
+ await Promise.race([app.whenStable(), fallback]);
630
+ } catch {
631
+ /* ignored — fallback timer still resolves */
632
+ } finally {
633
+ if (timer !== undefined) clearTimeout(timer);
634
+ }
635
+ };
636
+
609
637
  /* `runWithViewTransition` wraps a callback in `document.startViewTransition`
610
638
  for a smooth crossfade across full re-bootstraps. Queueing is NOT needed
611
639
  here because `handleAngularUpdate` already serializes incoming messages
@@ -714,12 +742,14 @@ const handleFullUpdate = async (message: HMRMessage) => {
714
742
  // `bootstrapAngularModule` returns — the route activation
715
743
  // chain (loadComponent → dynamic import → instantiate) runs
716
744
  // asynchronously after the root app reports bootstrapped.
717
- // Keep the flag on for a brief grace window so those lazy
718
- // components also restore from cache. 750ms is generous
719
- // enough for typical chunk loads (~50-200ms cold) and short
720
- // enough that a user can't navigate within the window —
721
- // preventing stale-state on subsequent navigations.
722
- setTimeout(endHmrPreservedReboot, 750);
745
+ // Wait for the application to become stable so those lazy
746
+ // components have constructed and called `preserveAcrossHmr`
747
+ // before we close the restoration window. `whenStable`
748
+ // resolves when there are no pending tasks (lazy chunk
749
+ // loads, microtasks, scheduled CD) — strictly event-based,
750
+ // no fixed timer needed.
751
+ await waitForAppStable();
752
+ endHmrPreservedReboot();
723
753
  }
724
754
  };
725
755
 
@@ -744,6 +774,7 @@ type HmrPreserveScope = typeof globalThis & {
744
774
  __ABS_HMR_TRACKED_INSTANCES__?: Set<WeakRef<object>>;
745
775
  __ABS_HMR_INSTANCE_KEYS__?: WeakMap<object, string>;
746
776
  __ABS_HMR_REBOOT_IN_PROGRESS__?: { value: boolean };
777
+ __ABS_HMR_REBOOT_STATS__?: { captured: number; restoredKeys: Set<string> };
747
778
  };
748
779
 
749
780
  const isPreservableValue = (value: unknown, depth = 0): boolean => {
@@ -768,11 +799,20 @@ const isPreservableValue = (value: unknown, depth = 0): boolean => {
768
799
  return false;
769
800
  };
770
801
 
802
+ const getHmrRebootStats = (scope: HmrPreserveScope) =>
803
+ (scope.__ABS_HMR_REBOOT_STATS__ ??= {
804
+ captured: 0,
805
+ restoredKeys: new Set<string>()
806
+ });
807
+
771
808
  const captureHmrPreservedInstanceStates = () => {
772
809
  const scope = globalThis as HmrPreserveScope;
773
810
  const tracker = scope.__ABS_HMR_TRACKED_INSTANCES__;
774
811
 
775
812
  const flag = (scope.__ABS_HMR_REBOOT_IN_PROGRESS__ ??= { value: false });
813
+ const stats = getHmrRebootStats(scope);
814
+ stats.captured = 0;
815
+ stats.restoredKeys.clear();
776
816
 
777
817
  if (!tracker || tracker.size === 0) {
778
818
  // Nothing tracked, but still flip the flag so any service/component
@@ -815,6 +855,7 @@ const captureHmrPreservedInstanceStates = () => {
815
855
  if (isPreservableValue(value)) props[prop] = value;
816
856
  }
817
857
  cache.set(fullKey, props);
858
+ stats.captured++;
818
859
  }
819
860
 
820
861
  dead.forEach((ref) => tracker.delete(ref));
@@ -826,4 +867,16 @@ const endHmrPreservedReboot = () => {
826
867
  const scope = globalThis as HmrPreserveScope;
827
868
  const flag = scope.__ABS_HMR_REBOOT_IN_PROGRESS__;
828
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
+ }
829
882
  };
@@ -72,6 +72,35 @@ const updateCounter: { value: number } =
72
72
  globalScope.__ANGULAR_HMR_UPDATE_COUNT__ ??
73
73
  (globalScope.__ANGULAR_HMR_UPDATE_COUNT__ = { value: 0 });
74
74
 
75
+ /* Cheap structural fingerprint. Functions render as 'fn' (treated as
76
+ opaque — they change on every module reload but the static config
77
+ like provider tokens, useValue payloads, etc. is what we care
78
+ about). Objects walk depth-bounded with sorted keys so key order
79
+ doesn't cause spurious diffs. Used both for component-level
80
+ provider arrays and for page-level `routes`/`providers` exports. */
81
+ const fingerprint = (value: unknown, depth = 0): string => {
82
+ if (depth > 6) return '~deep~';
83
+ if (value === null) return 'null';
84
+ if (value === undefined) return 'undef';
85
+ if (typeof value === 'function') return 'fn';
86
+ if (typeof value === 'symbol') return value.toString();
87
+ if (Array.isArray(value)) {
88
+ return (
89
+ '[' + value.map((v) => fingerprint(v, depth + 1)).join(',') + ']'
90
+ );
91
+ }
92
+ if (typeof value === 'object') {
93
+ const obj = value as Record<string, unknown>;
94
+ const entries = Object.entries(obj)
95
+ .map(([k, v]): [string, string] => [k, fingerprint(v, depth + 1)])
96
+ .sort(([a], [b]) => a.localeCompare(b));
97
+
98
+ return '{' + entries.map(([k, v]) => `${k}:${v}`).join(',') + '}';
99
+ }
100
+
101
+ return JSON.stringify(value);
102
+ };
103
+
75
104
  const hasInjectorProviderChanges = (
76
105
  oldCtor: ComponentCtor,
77
106
  newCtor: ComponentCtor
@@ -81,7 +110,7 @@ const hasInjectorProviderChanges = (
81
110
  const newP = newCtor.ɵinj?.providers;
82
111
  if (!Array.isArray(oldP) || !Array.isArray(newP)) return false;
83
112
 
84
- return oldP.length !== newP.length;
113
+ return fingerprint(oldP) !== fingerprint(newP);
85
114
  };
86
115
 
87
116
  const hasComponentProviderChanges = (
@@ -91,8 +120,21 @@ const hasComponentProviderChanges = (
91
120
  if (!oldCtor.ɵcmp || !newCtor.ɵcmp) return false;
92
121
  const oldResolver = oldCtor.ɵcmp.providersResolver;
93
122
  const newResolver = newCtor.ɵcmp.providersResolver;
123
+ // Defined-ness flip — added/removed `providers: [...]` entirely.
124
+ if ((oldResolver === undefined) !== (newResolver === undefined))
125
+ return true;
126
+ if (typeof oldResolver !== 'function' || typeof newResolver !== 'function')
127
+ return false;
94
128
 
95
- return (oldResolver === undefined) !== (newResolver === undefined);
129
+ // `providersResolver` is the function the Angular compiler emits to
130
+ // merge a component's `providers` array into the element injector.
131
+ // Its source body inlines the provider tokens and useValue/useFactory
132
+ // references, so a change to the user's `providers: [...]` array
133
+ // produces a different function body. Comparing `toString()` catches
134
+ // content changes that the old length/defined-ness check missed —
135
+ // e.g. swapping `useValue: 'foo'` for `useValue: 'bar'` while
136
+ // keeping the array length identical.
137
+ return oldResolver.toString() !== newResolver.toString();
96
138
  };
97
139
 
98
140
  const hasProviderChanges = (oldCtor: ComponentCtor, newCtor: ComponentCtor) => {
@@ -265,29 +307,6 @@ const pageExportRecords = ((
265
307
  globalThis as { __ABS_HMR_PAGE_EXPORTS__?: Map<string, PageExportRecord> }
266
308
  ).__ABS_HMR_PAGE_EXPORTS__ ??= new Map<string, PageExportRecord>());
267
309
 
268
- const fingerprint = (value: unknown, depth = 0): string => {
269
- if (depth > 6) return '~deep~';
270
- if (value === null) return 'null';
271
- if (value === undefined) return 'undef';
272
- if (typeof value === 'function') return 'fn';
273
- if (typeof value === 'symbol') return value.toString();
274
- if (Array.isArray(value)) {
275
- return (
276
- '[' + value.map((v) => fingerprint(v, depth + 1)).join(',') + ']'
277
- );
278
- }
279
- if (typeof value === 'object') {
280
- const obj = value as Record<string, unknown>;
281
- const entries = Object.entries(obj)
282
- .map(([k, v]): [string, string] => [k, fingerprint(v, depth + 1)])
283
- .sort(([a], [b]) => a.localeCompare(b));
284
-
285
- return '{' + entries.map(([k, v]) => `${k}:${v}`).join(',') + '}';
286
- }
287
-
288
- return JSON.stringify(value);
289
- };
290
-
291
310
  const recordPageExports = (
292
311
  sourceId: string,
293
312
  routes: unknown,
@@ -17,9 +17,13 @@ export declare const preserveAcrossHmr: (instance: object, key?: string | number
17
17
  * are skipped — the new instance gets fresh ones from its new
18
18
  * injector, just like at first bootstrap. */
19
19
  export declare const captureTrackedInstanceStates: () => void;
20
- /** Clear the active-reboot flag. Called by the HMR client after the
21
- * new app has finished bootstrapping (success or failure — wrap in
22
- * `try/finally`). After this, `preserveAcrossHmr` calls will track
23
- * but won't restore so navigating to a route after HMR doesn't
24
- * resurrect stale state from the last reboot. */
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. */
25
29
  export declare const endHmrReboot: () => void;
@@ -56,7 +56,11 @@ declare global {
56
56
  __SVELTE_COMPONENT__?: Record<string, unknown>;
57
57
  __ABS_SVELTE_ISLAND_HTML__?: Record<string, string>;
58
58
  __SVELTE_UNMOUNT__?: () => void;
59
- __ANGULAR_APP__?: { destroy: () => void; tick: () => void } | null;
59
+ __ANGULAR_APP__?: {
60
+ destroy: () => void;
61
+ tick: () => void;
62
+ whenStable: () => Promise<void>;
63
+ } | null;
60
64
  __HMR_SKIP_HYDRATION__?: boolean;
61
65
  __HMR_NEW_PAGE_CLASS__?: unknown;
62
66
  __NG_REPLACE_METADATA__?: (...args: unknown[]) => void;
package/package.json CHANGED
@@ -349,5 +349,5 @@
349
349
  ]
350
350
  }
351
351
  },
352
- "version": "0.19.0-beta.750"
352
+ "version": "0.19.0-beta.751"
353
353
  }