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

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
@@ -786,17 +826,15 @@ const captureHmrPreservedInstanceStates = () => {
786
826
 
787
827
  const cache = (scope.__ABS_HMR_INSTANCE_STATE__ ??= new Map());
788
828
  const keyMap = scope.__ABS_HMR_INSTANCE_KEYS__;
789
- const dead: WeakRef<object>[] = [];
790
829
  const seen = new Set<string>();
791
830
 
792
831
  cache.clear();
793
832
 
794
833
  for (const ref of tracker) {
795
834
  const instance = ref.deref();
796
- if (!instance) {
797
- dead.push(ref);
798
- continue;
799
- }
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;
800
838
 
801
839
  const className = instance.constructor?.name;
802
840
  if (!className || className === 'Object') continue;
@@ -815,9 +853,17 @@ const captureHmrPreservedInstanceStates = () => {
815
853
  if (isPreservableValue(value)) props[prop] = value;
816
854
  }
817
855
  cache.set(fullKey, props);
856
+ stats.captured++;
818
857
  }
819
858
 
820
- dead.forEach((ref) => tracker.delete(ref));
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();
821
867
 
822
868
  flag.value = true;
823
869
  };
@@ -826,4 +872,16 @@ const endHmrPreservedReboot = () => {
826
872
  const scope = globalThis as HmrPreserveScope;
827
873
  const flag = scope.__ABS_HMR_REBOOT_IN_PROGRESS__;
828
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
+ }
829
887
  };
@@ -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) & {
@@ -72,6 +73,35 @@ const updateCounter: { value: number } =
72
73
  globalScope.__ANGULAR_HMR_UPDATE_COUNT__ ??
73
74
  (globalScope.__ANGULAR_HMR_UPDATE_COUNT__ = { value: 0 });
74
75
 
76
+ /* Cheap structural fingerprint. Functions render as 'fn' (treated as
77
+ opaque — they change on every module reload but the static config
78
+ like provider tokens, useValue payloads, etc. is what we care
79
+ about). Objects walk depth-bounded with sorted keys so key order
80
+ doesn't cause spurious diffs. Used both for component-level
81
+ provider arrays and for page-level `routes`/`providers` exports. */
82
+ const fingerprint = (value: unknown, depth = 0): string => {
83
+ if (depth > 6) return '~deep~';
84
+ if (value === null) return 'null';
85
+ if (value === undefined) return 'undef';
86
+ if (typeof value === 'function') return 'fn';
87
+ if (typeof value === 'symbol') return value.toString();
88
+ if (Array.isArray(value)) {
89
+ return (
90
+ '[' + value.map((v) => fingerprint(v, depth + 1)).join(',') + ']'
91
+ );
92
+ }
93
+ if (typeof value === 'object') {
94
+ const obj = value as Record<string, unknown>;
95
+ const entries = Object.entries(obj)
96
+ .map(([k, v]): [string, string] => [k, fingerprint(v, depth + 1)])
97
+ .sort(([a], [b]) => a.localeCompare(b));
98
+
99
+ return '{' + entries.map(([k, v]) => `${k}:${v}`).join(',') + '}';
100
+ }
101
+
102
+ return JSON.stringify(value);
103
+ };
104
+
75
105
  const hasInjectorProviderChanges = (
76
106
  oldCtor: ComponentCtor,
77
107
  newCtor: ComponentCtor
@@ -81,7 +111,7 @@ const hasInjectorProviderChanges = (
81
111
  const newP = newCtor.ɵinj?.providers;
82
112
  if (!Array.isArray(oldP) || !Array.isArray(newP)) return false;
83
113
 
84
- return oldP.length !== newP.length;
114
+ return fingerprint(oldP) !== fingerprint(newP);
85
115
  };
86
116
 
87
117
  const hasComponentProviderChanges = (
@@ -91,8 +121,21 @@ const hasComponentProviderChanges = (
91
121
  if (!oldCtor.ɵcmp || !newCtor.ɵcmp) return false;
92
122
  const oldResolver = oldCtor.ɵcmp.providersResolver;
93
123
  const newResolver = newCtor.ɵcmp.providersResolver;
124
+ // Defined-ness flip — added/removed `providers: [...]` entirely.
125
+ if ((oldResolver === undefined) !== (newResolver === undefined))
126
+ return true;
127
+ if (typeof oldResolver !== 'function' || typeof newResolver !== 'function')
128
+ return false;
94
129
 
95
- return (oldResolver === undefined) !== (newResolver === undefined);
130
+ // `providersResolver` is the function the Angular compiler emits to
131
+ // merge a component's `providers` array into the element injector.
132
+ // Its source body inlines the provider tokens and useValue/useFactory
133
+ // references, so a change to the user's `providers: [...]` array
134
+ // produces a different function body. Comparing `toString()` catches
135
+ // content changes that the old length/defined-ness check missed —
136
+ // e.g. swapping `useValue: 'foo'` for `useValue: 'bar'` while
137
+ // keeping the array length identical.
138
+ return oldResolver.toString() !== newResolver.toString();
96
139
  };
97
140
 
98
141
  const hasProviderChanges = (oldCtor: ComponentCtor, newCtor: ComponentCtor) => {
@@ -175,6 +218,55 @@ const patchConstructor = (entry: RegistryEntry, newCtor: ComponentCtor) => {
175
218
  entry.registeredAt = Date.now();
176
219
  };
177
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
+
178
270
  const applyUpdate = (id: string, newCtor: unknown) => {
179
271
  if (!isComponentCtor(newCtor)) return false;
180
272
 
@@ -209,6 +301,13 @@ const applyUpdate = (id: string, newCtor: unknown) => {
209
301
 
210
302
  try {
211
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);
212
311
 
213
312
  return true;
214
313
  } catch (err) {
@@ -220,6 +319,15 @@ const applyUpdate = (id: string, newCtor: unknown) => {
220
319
 
221
320
  const refresh = () => {
222
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();
223
331
  try {
224
332
  window.__ANGULAR_APP__.tick();
225
333
  } catch (err) {
@@ -265,29 +373,6 @@ const pageExportRecords = ((
265
373
  globalThis as { __ABS_HMR_PAGE_EXPORTS__?: Map<string, PageExportRecord> }
266
374
  ).__ABS_HMR_PAGE_EXPORTS__ ??= new Map<string, PageExportRecord>());
267
375
 
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
376
  const recordPageExports = (
292
377
  sourceId: string,
293
378
  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.752"
353
353
  }