@absolutejs/absolute 0.19.0-beta.853 → 0.19.0-beta.855

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.
@@ -1,684 +0,0 @@
1
- import type {} from '../../../types/globals';
2
- /* Angular HMR — Re-Bootstrap with View Transitions API (Zero Flicker)
3
- DEV MODE ONLY — never active in production.
4
-
5
- Strategy:
6
- 1. Capture component/service state via `preserveAcrossHmr` opt-ins
7
- 2. Use document.startViewTransition() — browser captures a screenshot
8
- 3. Destroy old app, recreate root element, import new module
9
- 4. bootstrapApplication() renders new content (behind the screenshot)
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
16
-
17
- document.startViewTransition() is the native browser API for page
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. */
22
-
23
- import {
24
- captureTrackedInstanceStates,
25
- endHmrReboot
26
- } from '../../../angular/hmrPreserveCore';
27
- import { ANGULAR_INIT_TIMEOUT_MS } from '../constants';
28
- import {
29
- saveFormState,
30
- restoreFormState,
31
- saveScrollState,
32
- restoreScrollState
33
- } from '../domState';
34
- import { detectCurrentFramework, findIndexPath } from '../frameworkDetect';
35
- import { showHmrToast } from '../hmrToast';
36
-
37
- type AngularUpdateType =
38
- | 'template'
39
- | 'style-component'
40
- | 'class-component'
41
- | 'service-method-only'
42
- | 'service-with-side-effects'
43
- | 'route'
44
- | 'reboot'
45
- // Legacy types kept for back-compat with global CSS edits and pre-2.1
46
- // builds. Treated as fast-path triggers (style → swap stylesheet,
47
- // logic → fast-patch + reboot fallback).
48
- | 'style'
49
- | 'css-only'
50
- | 'logic'
51
- | 'full';
52
-
53
- type HMRMessage = {
54
- data: {
55
- cssBaseName?: string;
56
- cssUrl?: string;
57
- editSourceFile?: string;
58
- html?: string;
59
- manifest?: Record<string, string>;
60
- pageModuleUrl?: string;
61
- reason?: string;
62
- serverDuration?: number;
63
- sourceFile?: string;
64
- updateType?: AngularUpdateType;
65
- };
66
- };
67
-
68
- type AngularHmrApi = {
69
- applyUpdate: (id: string, newCtor: unknown) => boolean;
70
- getRegistry?: () => Map<string, unknown>;
71
- refresh: () => void;
72
- hasPageExportsChanged?: (sourceId: string) => boolean;
73
- };
74
-
75
- type ViewTransitionDocument = Document & {
76
- startViewTransition?: (updateCallback: () => Promise<void>) => {
77
- finished: Promise<void>;
78
- };
79
- };
80
-
81
- type AngularComponentExport = ((...args: unknown[]) => unknown) & {
82
- ɵcmp?: unknown;
83
- };
84
-
85
- const isAngularComponentExport = (
86
- value: unknown
87
- ): value is AngularComponentExport => {
88
- if (typeof value !== 'function') {
89
- return false;
90
- }
91
-
92
- return 'ɵcmp' in value && Boolean(value.ɵcmp);
93
- };
94
-
95
- const swapStylesheet = (
96
- cssUrl: string,
97
- cssBaseName: string,
98
- framework: string
99
- ) => {
100
- let existingLink: HTMLLinkElement | null = null;
101
- document.querySelectorAll('link[rel="stylesheet"]').forEach((link) => {
102
- const linkEl = link instanceof HTMLLinkElement ? link : null;
103
- const href = linkEl?.getAttribute('href') ?? '';
104
- if (href.includes(cssBaseName) || href.includes(framework)) {
105
- existingLink = linkEl;
106
- }
107
- });
108
- if (!existingLink) return;
109
-
110
- const capturedExisting: HTMLLinkElement = existingLink;
111
- const newLink = document.createElement('link');
112
- newLink.rel = 'stylesheet';
113
- newLink.href = `${cssUrl}?t=${Date.now()}`;
114
- newLink.onload = function () {
115
- if (capturedExisting && capturedExisting.parentNode)
116
- capturedExisting.remove();
117
- };
118
- document.head.appendChild(newLink);
119
- };
120
-
121
- // ─── Wait for Angular bootstrap (event-based, no polling) ───
122
- // Installs a property setter trap on window.__ANGULAR_APP__ that
123
- // resolves the promise the instant the bootstrap code writes to it.
124
- // Falls back to a short timeout in case the setter is bypassed.
125
-
126
- const waitForAngularApp = () => {
127
- if (window.__ANGULAR_APP__) return Promise.resolve();
128
-
129
- const { promise, resolve } = Promise.withResolvers<void>();
130
- const timeout = setTimeout(resolve, ANGULAR_INIT_TIMEOUT_MS);
131
-
132
- let stored = window.__ANGULAR_APP__;
133
-
134
- Object.defineProperty(window, '__ANGULAR_APP__', {
135
- configurable: true,
136
- enumerable: true,
137
- get() {
138
- return stored;
139
- },
140
- set(val) {
141
- stored = val;
142
- Object.defineProperty(window, '__ANGULAR_APP__', {
143
- configurable: true,
144
- enumerable: true,
145
- value: val,
146
- writable: true
147
- });
148
- clearTimeout(timeout);
149
- resolve();
150
- }
151
- });
152
-
153
- return promise;
154
- };
155
-
156
- // ============================================================
157
- // FAST UPDATE — Runtime patching without destroy/re-bootstrap
158
- // ============================================================
159
-
160
- const suppressNg0912 = () => {
161
- const origWarn = console.warn;
162
- console.warn = function (...args: unknown[]) {
163
- if (typeof args[0] === 'string' && args[0].includes('NG0912')) return;
164
- origWarn.apply(console, args);
165
- };
166
-
167
- return origWarn;
168
- };
169
-
170
- const tryPatchExport = (
171
- exportName: string,
172
- newModule: Record<string, unknown>,
173
- registry: Map<string, unknown>,
174
- hmr: AngularHmrApi,
175
- sourceFile: string
176
- ) => {
177
- const exported = newModule[exportName];
178
- if (!isAngularComponentExport(exported)) return 'skip';
179
-
180
- const registryId = `${sourceFile}#${exportName}`;
181
- if (!registry.has(registryId)) return 'skip';
182
-
183
- const success = hmr.applyUpdate(registryId, exported);
184
- if (!success) return 'fail';
185
-
186
- return 'patched';
187
- };
188
-
189
- const patchRegisteredComponents = (
190
- newModule: Record<string, unknown>,
191
- registry: Map<string, unknown>,
192
- hmr: AngularHmrApi,
193
- sourceFile: string
194
- ) => {
195
- let patchedAny = false;
196
- const allPatched = Object.keys(newModule).every((exportName) => {
197
- const result = tryPatchExport(
198
- exportName,
199
- newModule,
200
- registry,
201
- hmr,
202
- sourceFile
203
- );
204
- if (result === 'skip') {
205
- return true;
206
- }
207
- if (result === 'fail') {
208
- return false;
209
- }
210
- patchedAny = true;
211
-
212
- return true;
213
- });
214
-
215
- return { allPatched, patchedAny };
216
- };
217
-
218
- type FastPatchWindow = Window & {
219
- __ANGULAR_HMR_FAST_PATCH__?: boolean;
220
- };
221
-
222
- const attemptFastPatch = async (
223
- indexPath: string,
224
- registry: Map<string, unknown>,
225
- hmr: AngularHmrApi,
226
- sourceFile: string,
227
- origWarn: typeof console.warn
228
- ) => {
229
- // The bundled page chunk's top-level code re-bootstraps the Angular app
230
- // (destroy + bootstrapApplication). For fast-patch we just need to read
231
- // the freshly-built component classes — not re-bootstrap. Setting this
232
- // flag tells the chunk to skip its bootstrap section and only run the
233
- // `export * from '<page-module>'` line. Paired with the guard added in
234
- // `src/build/compileAngular.ts` HMR template.
235
- const w = window as FastPatchWindow;
236
- w.__ANGULAR_HMR_FAST_PATCH__ = true;
237
- try {
238
- const newModule = await import(`${indexPath}?t=${Date.now()}`);
239
-
240
- // Page-level `routes` / `providers` changed? Those values are read
241
- // once during `bootstrapApplication`; an in-place component patch
242
- // won't re-wire the running router or root injector. The chunk
243
- // records its current fingerprint each time it evaluates (initial
244
- // bootstrap + every fast-patch import), so a change between the
245
- // previous and current evaluation means we need to fall back to a
246
- // full re-bootstrap.
247
- if (hmr.hasPageExportsChanged?.(sourceFile)) {
248
- console.warn = origWarn;
249
-
250
- return false;
251
- }
252
-
253
- // NG0912 warnings fire during `applyUpdate` (Angular re-registers
254
- // the new component class while the old one is still live). Keep
255
- // the suppression active through the patch, restore right before
256
- // `refresh()` so any non-NG0912 warnings during `tick()` surface.
257
- const { allPatched, patchedAny } = patchRegisteredComponents(
258
- newModule,
259
- registry,
260
- hmr,
261
- sourceFile
262
- );
263
-
264
- console.warn = origWarn;
265
-
266
- if (!patchedAny) return false;
267
- if (!allPatched) return false;
268
-
269
- hmr.refresh();
270
-
271
- return true;
272
- } catch (err) {
273
- console.warn = origWarn;
274
- console.warn('[HMR] Angular fast update failed, falling back:', err);
275
-
276
- return false;
277
- } finally {
278
- delete w.__ANGULAR_HMR_FAST_PATCH__;
279
- }
280
- };
281
-
282
- /* Fast update — patch live component prototypes without destroying the app.
283
- Returns true when at least one registered component was successfully
284
- patched (and no patch failed); false means we couldn't fast-patch and
285
- the caller should fall back to a full re-bootstrap.
286
- Failures we explicitly fall back on:
287
- - file's source isn't tracked in the component registry yet
288
- - changed file has no Angular components (e.g. a service or routes file)
289
- - any component's `applyUpdate` returned false (provider change, etc.)
290
- - dynamic import failed */
291
- const handleFastUpdate = async (message: HMRMessage) => {
292
- const hmr = window.__ANGULAR_HMR__;
293
- if (!hmr || !hmr.getRegistry) return false;
294
-
295
- const registry = hmr.getRegistry();
296
- if (registry.size === 0) return false;
297
-
298
- const indexPath = findIndexPath(
299
- message.data.manifest,
300
- message.data.sourceFile,
301
- 'angular'
302
- );
303
- if (!indexPath) return false;
304
-
305
- const origWarn = suppressNg0912();
306
-
307
- const patched = await attemptFastPatch(
308
- indexPath,
309
- registry,
310
- hmr,
311
- message.data.sourceFile || '',
312
- origWarn
313
- );
314
-
315
- if (patched && message.data.cssUrl) {
316
- swapStylesheet(
317
- message.data.cssUrl,
318
- message.data.cssBaseName || '',
319
- 'angular'
320
- );
321
- }
322
-
323
- return patched;
324
- };
325
-
326
- // ============================================================
327
- // MAIN ENTRY POINT
328
- // ============================================================
329
-
330
- /* HMR updates are serialized through a single in-flight slot. While one
331
- update is running (fast or full), additional incoming updates collapse
332
- into one pending slot — only the latest matters because each rebuild
333
- produces a chunk that supersedes prior ones for the same source file.
334
- Without this, two rapid edits could:
335
- - run two `startViewTransition`s and have the browser abort the first
336
- mid-callback (the original "Transition was skipped" symptom), or
337
- - run two `attemptFastPatch`s that both call `applyUpdate` on the same
338
- registry entries, racing on prototype swaps. */
339
- let activeMessage: Promise<void> | null = null;
340
- let pendingMessage: HMRMessage | null = null;
341
-
342
- /* Surgical fast-path stubs.
343
- *
344
- * Phase 2's dynamic-import-based handlers (component-style /
345
- * template / service-method-only) are intentionally absent here —
346
- * they were architecturally wrong (dynamic-importing the rebuilt
347
- * page chunk created a parallel class identity, tripping NG0912
348
- * collisions and producing scope-ID drift on Emulated styles). The
349
- * surgical pipeline that replaces them uses Angular'''s
350
- * `ɵɵreplaceMetadata` primitive — see SURGICAL_HMR.md.
351
- *
352
- * Until that pipeline lands, classifications other than
353
- * `class-component` fall through to the existing reboot path. The
354
- * toast tells the developer why. */
355
- const handleTemplateUpdate = async (_message: HMRMessage): Promise<boolean> =>
356
- false;
357
-
358
- const handleComponentStyleUpdate = async (
359
- _message: HMRMessage
360
- ): Promise<boolean> => false;
361
-
362
- const handleServiceMethodSwap = async (
363
- _message: HMRMessage
364
- ): Promise<boolean> => false;
365
-
366
- const logRebootReason = (message: HMRMessage) => {
367
- const reason = message.data.reason;
368
- const updateType = message.data.updateType;
369
- if (!reason && !updateType) return;
370
- console.info(
371
- `[HMR] Angular reboot — ${updateType ?? 'unknown'}: ${reason ?? '(no reason given)'}`
372
- );
373
- // Surface the same info as a brief on-page toast so the developer
374
- // sees it without opening devtools. Toasts auto-dismiss after a
375
- // few seconds.
376
- showHmrToast({
377
- editSourceFile: message.data.editSourceFile,
378
- reason,
379
- updateType
380
- });
381
- };
382
-
383
- const processMessage = async (message: HMRMessage) => {
384
- const updateType = message.data.updateType ?? 'logic';
385
-
386
- switch (updateType) {
387
- case 'template': {
388
- const ok = await handleTemplateUpdate(message);
389
- if (ok) return;
390
- break;
391
- }
392
- case 'style-component': {
393
- const ok = await handleComponentStyleUpdate(message);
394
- if (ok) return;
395
- break;
396
- }
397
- case 'service-method-only': {
398
- const ok = await handleServiceMethodSwap(message);
399
- if (ok) return;
400
- break;
401
- }
402
- case 'class-component':
403
- case 'logic': {
404
- // Existing prototype-swap fast-patch. Falls through to reboot
405
- // on any failure (provider change, page-export change, etc.).
406
- try {
407
- const patched = await handleFastUpdate(message);
408
- if (patched) return;
409
- } catch (err) {
410
- console.warn(
411
- '[HMR] Angular fast update threw, falling back to reboot:',
412
- err
413
- );
414
- }
415
- break;
416
- }
417
- case 'service-with-side-effects':
418
- case 'route':
419
- case 'reboot':
420
- case 'full':
421
- // Explicit reboot signals — skip the fast path entirely and
422
- // log why so the developer can see the classification.
423
- break;
424
- default:
425
- // Unknown update type — be conservative and reboot.
426
- break;
427
- }
428
-
429
- // Falling through: full re-bootstrap. Components and services that
430
- // opted into `preserveAcrossHmr(this)` keep their state; anything
431
- // that didn't opt in is reset to its class-field defaults. The
432
- // summary log emitted by `endHmrReboot` lists what was preserved.
433
- logRebootReason(message);
434
- await handleFullUpdate(message);
435
- };
436
-
437
- export const handleAngularUpdate = (message: HMRMessage) => {
438
- if (detectCurrentFramework() !== 'angular') return;
439
-
440
- const updateType = message.data.updateType ?? 'logic';
441
-
442
- if (
443
- (updateType === 'style' || updateType === 'css-only') &&
444
- message.data.cssUrl
445
- ) {
446
- // Global CSS-only updates: swap the stylesheet in place. These
447
- // run outside the activeMessage queue because they can't conflict
448
- // with an in-flight component update.
449
- swapStylesheet(
450
- message.data.cssUrl,
451
- message.data.cssBaseName || '',
452
- 'angular'
453
- );
454
-
455
- return;
456
- }
457
-
458
- if (activeMessage) {
459
- // Coalesce: an update is in flight, queue this one (replacing any
460
- // earlier queued update, which is now stale).
461
- pendingMessage = message;
462
-
463
- return;
464
- }
465
-
466
- activeMessage = processMessage(message).finally(() => {
467
- activeMessage = null;
468
- if (pendingMessage) {
469
- const next = pendingMessage;
470
- pendingMessage = null;
471
- handleAngularUpdate(next);
472
- }
473
- });
474
- };
475
-
476
- // ============================================================
477
- // RE-BOOTSTRAP WITH VIEW TRANSITIONS API
478
- // ============================================================
479
-
480
- const findRootSelector = (container: Element) => {
481
- const candidates = container.querySelectorAll('*');
482
- for (let idx = 0; idx < candidates.length; idx++) {
483
- const candidate = candidates[idx];
484
- if (!candidate) continue;
485
- const tag = candidate.tagName.toLowerCase();
486
- if (tag.includes('-')) return tag;
487
- }
488
-
489
- return null;
490
- };
491
-
492
- const destroyAngularApp = () => {
493
- if (!window.__ANGULAR_APP__) return;
494
-
495
- try {
496
- window.__ANGULAR_APP__.destroy();
497
- } catch {
498
- /* ignored */
499
- }
500
- window.__ANGULAR_APP__ = null;
501
- };
502
-
503
- const bootstrapAngularModule = async (
504
- indexPath: string,
505
- rootSelector: string | null,
506
- rootContainer: Element
507
- ) => {
508
- if (rootSelector && !rootContainer.querySelector(rootSelector)) {
509
- rootContainer.appendChild(document.createElement(rootSelector));
510
- }
511
-
512
- window.__HMR_SKIP_HYDRATION__ = true;
513
-
514
- const origWarn = suppressNg0912();
515
-
516
- await import(`${indexPath}?t=${Date.now()}`);
517
- await waitForAngularApp();
518
-
519
- console.warn = origWarn;
520
- };
521
-
522
- const tickAngularApp = () => {
523
- if (!window.__ANGULAR_APP__) return;
524
-
525
- try {
526
- window.__ANGULAR_APP__.tick();
527
- } catch {
528
- /* ignored */
529
- }
530
- };
531
-
532
- /* Resolve when Angular reports the application is stable: no pending
533
- microtasks, scheduled CD, or in-flight lazy chunk loads. Used to gate
534
- the close of the HMR restoration window so lazy-route components get
535
- a chance to construct (and call `preserveAcrossHmr`) before
536
- `rebootInProgress` flips back to false. Falls back after a generous
537
- ceiling in the unlikely case `whenStable` never resolves (e.g. an
538
- infinite retry on a service the new app never finishes initializing) —
539
- we'd rather close the window than leave HMR wedged forever. */
540
- const APP_STABLE_FALLBACK_MS = 10_000;
541
-
542
- const waitForAppStable = async () => {
543
- const app = window.__ANGULAR_APP__;
544
- if (!app || typeof app.whenStable !== 'function') return;
545
-
546
- let timer: ReturnType<typeof setTimeout> | undefined;
547
- const fallback = new Promise<void>((resolve) => {
548
- timer = setTimeout(resolve, APP_STABLE_FALLBACK_MS);
549
- });
550
-
551
- try {
552
- await Promise.race([app.whenStable(), fallback]);
553
- } catch {
554
- /* ignored — fallback timer still resolves */
555
- } finally {
556
- if (timer !== undefined) clearTimeout(timer);
557
- }
558
- };
559
-
560
- /* `runWithViewTransition` wraps a callback in `document.startViewTransition`
561
- for a smooth crossfade across full re-bootstraps. Queueing is NOT needed
562
- here because `handleAngularUpdate` already serializes incoming messages
563
- through the outer `activeMessage`/`pendingMessage` slots — only one
564
- update runs at a time, so a new `startViewTransition` never aborts an
565
- in-flight one mid-callback. */
566
- const runWithViewTransition = async (updateFn: () => Promise<void>) => {
567
- const doc: ViewTransitionDocument = document;
568
-
569
- if (typeof doc.startViewTransition !== 'function') {
570
- try {
571
- await updateFn();
572
- } catch (err) {
573
- console.warn('[HMR] Angular update failed (non-fatal):', err);
574
- }
575
-
576
- return;
577
- }
578
-
579
- let styleEl: HTMLStyleElement | null = null;
580
- try {
581
- styleEl = document.createElement('style');
582
- styleEl.textContent =
583
- '::view-transition-old(root),::view-transition-new(root){animation:none!important}';
584
- document.head.appendChild(styleEl);
585
- } catch {
586
- /* ignored */
587
- }
588
-
589
- let updatePromise: Promise<void> = Promise.resolve();
590
- try {
591
- const transition = doc.startViewTransition(() => {
592
- updatePromise = updateFn();
593
-
594
- return updatePromise;
595
- });
596
- // Wait for both the visual transition and the update callback.
597
- // `transition.finished` rejects with AbortError when a new transition
598
- // supersedes this one — swallow that since we serialize updates so
599
- // it shouldn't happen, and even if it does we still want to wait
600
- // for `updateFn` to complete before releasing the next update.
601
- await Promise.all([
602
- transition.finished.catch(() => {
603
- /* skipped */
604
- }),
605
- updatePromise.catch((err) => {
606
- console.warn('[HMR] Angular update failed (non-fatal):', err);
607
- })
608
- ]);
609
- } catch (err) {
610
- console.warn('[HMR] Angular update failed (non-fatal):', err);
611
- // If startViewTransition itself threw, run the update directly so
612
- // HMR still applies (loses the crossfade but preserves correctness).
613
- try {
614
- await updateFn();
615
- } catch (innerErr) {
616
- console.warn('[HMR] Angular update failed (non-fatal):', innerErr);
617
- }
618
- } finally {
619
- if (styleEl && styleEl.parentNode) styleEl.remove();
620
- }
621
- };
622
-
623
- const handleFullUpdate = async (message: HMRMessage) => {
624
- // DOM-level state — preserved separately from instance state because
625
- // it lives in the document, not in component fields. Form values and
626
- // scroll position survive a full re-bootstrap regardless of whether
627
- // any component opted into `preserveAcrossHmr`.
628
- const scrollState = saveScrollState();
629
- const formState = saveFormState();
630
-
631
- if (message.data.cssUrl) {
632
- swapStylesheet(
633
- message.data.cssUrl,
634
- message.data.cssBaseName || '',
635
- 'angular'
636
- );
637
- }
638
-
639
- const rootContainer = document.getElementById('root') || document.body;
640
- const rootSelector = findRootSelector(rootContainer);
641
-
642
- const indexPath = findIndexPath(
643
- message.data.manifest,
644
- message.data.sourceFile,
645
- 'angular'
646
- );
647
- if (!indexPath) return;
648
-
649
- const doUpdate = async () => {
650
- // Snapshot every instance that opted into `preserveAcrossHmr(this)`
651
- // before destroying the app, and flip the reboot-in-progress flag
652
- // on. The new instances created during bootstrap will read cached
653
- // state back via the same helper while the flag is on. Both this
654
- // capture call and the user-facing `preserveAcrossHmr` helper
655
- // share the same `globalThis`-anchored cache via `hmrPreserveCore`.
656
- captureTrackedInstanceStates();
657
- try {
658
- destroyAngularApp();
659
- await bootstrapAngularModule(
660
- indexPath,
661
- rootSelector,
662
- rootContainer
663
- );
664
- tickAngularApp();
665
- restoreFormState(formState);
666
- restoreScrollState(scrollState);
667
- } finally {
668
- // Lazy-loaded child route components construct AFTER
669
- // `bootstrapAngularModule` returns — the route activation
670
- // chain (loadComponent → dynamic import → instantiate) runs
671
- // asynchronously after the root app reports bootstrapped.
672
- // Wait for the application to become stable so those lazy
673
- // components have constructed and called `preserveAcrossHmr`
674
- // before we close the restoration window. `whenStable`
675
- // resolves when there are no pending tasks (lazy chunk
676
- // loads, microtasks, scheduled CD) — strictly event-based,
677
- // no fixed timer needed.
678
- await waitForAppStable();
679
- endHmrReboot();
680
- }
681
- };
682
-
683
- await runWithViewTransition(doUpdate);
684
- };