@absolutejs/absolute 0.19.0-beta.845 → 0.19.0-beta.846

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.
Files changed (35) hide show
  1. package/dist/angular/components/core/streamingSlotRegistrar.js +1 -1
  2. package/dist/angular/components/core/streamingSlotRegistry.js +2 -2
  3. package/dist/angular/index.js +29 -21
  4. package/dist/angular/index.js.map +10 -9
  5. package/dist/angular/server.js +29 -21
  6. package/dist/angular/server.js.map +10 -9
  7. package/dist/build.js +939 -496
  8. package/dist/build.js.map +15 -12
  9. package/dist/cli/index.js +547 -286
  10. package/dist/client/index.js +16 -9
  11. package/dist/client/index.js.map +6 -5
  12. package/dist/dev/client/handlers/angular.ts +309 -19
  13. package/dist/dev/client/handlers/angularRuntime.ts +468 -0
  14. package/dist/dev/client/hmrToast.ts +150 -0
  15. package/dist/index.js +986 -543
  16. package/dist/index.js.map +16 -13
  17. package/dist/islands/index.js +16 -9
  18. package/dist/islands/index.js.map +6 -5
  19. package/dist/react/index.js +16 -9
  20. package/dist/react/index.js.map +6 -5
  21. package/dist/src/build/rewriteImports.d.ts +6 -14
  22. package/dist/src/build/rewriteImportsPlugin.d.ts +48 -0
  23. package/dist/src/dev/angular/editTypeDetection.d.ts +8 -0
  24. package/dist/src/dev/pathUtils.d.ts +3 -0
  25. package/dist/src/utils/buildDirectoryLock.d.ts +26 -3
  26. package/dist/src/utils/loadConfig.d.ts +5 -0
  27. package/dist/src/utils/resolveDevPort.d.ts +21 -0
  28. package/dist/src/utils/runtimeMode.d.ts +3 -0
  29. package/dist/svelte/index.js +16 -9
  30. package/dist/svelte/index.js.map +6 -5
  31. package/dist/types/build.d.ts +15 -0
  32. package/dist/types/globals.d.ts +12 -0
  33. package/dist/vue/index.js +16 -9
  34. package/dist/vue/index.js.map +6 -5
  35. package/package.json +1 -1
@@ -26,6 +26,21 @@ type AngularComponentDefinition = {
26
26
  providers?: unknown;
27
27
  providersResolver?: unknown;
28
28
  selectors?: unknown[];
29
+ styles?: string[];
30
+ encapsulation?: number;
31
+ template?: unknown;
32
+ consts?: unknown;
33
+ decls?: number;
34
+ vars?: number;
35
+ viewQuery?: unknown;
36
+ contentQueries?: unknown;
37
+ ngContentSelectors?: unknown;
38
+ dependencies?: unknown;
39
+ hostBindings?: unknown;
40
+ hostVars?: number;
41
+ hostAttrs?: unknown;
42
+ inputs?: unknown;
43
+ outputs?: unknown;
29
44
  };
30
45
 
31
46
  type ComponentCtor = (abstract new (...args: never[]) => unknown) & {
@@ -145,6 +160,45 @@ const hasProviderChanges = (oldCtor: ComponentCtor, newCtor: ComponentCtor) => {
145
160
  return false;
146
161
  };
147
162
 
163
+ /* Style-update batch buffer.
164
+ *
165
+ * When a component-CSS edit triggers HMR, the rebuilt page chunk
166
+ * re-evaluates with `__ANGULAR_HMR_STYLE_UPDATE_MODE__` set on the
167
+ * window. Inside that mode, every `register(id, newCtor)` call from
168
+ * the chunk's auto-registration block routes its newCtor straight
169
+ * into `applyStyleUpdate(id, newCtor)` instead of being a no-op
170
+ * (which is the default for already-registered IDs).
171
+ *
172
+ * This is the only way to reach CHILD-component classes — the page
173
+ * chunk only `export *`s the page's own module, so a top-level
174
+ * `Object.keys(newModule)` walk wouldn't find imported components.
175
+ * The registration block runs once per compiled file (page + every
176
+ * imported component), so it covers the whole subtree.
177
+ *
178
+ * The batch is consulted by `handleComponentStyleUpdate` after the
179
+ * chunk import resolves: if any registration's update returned false,
180
+ * the orchestrator falls through to a full reboot rather than leaving
181
+ * the page partially restyled. */
182
+
183
+ type StyleUpdateMode = typeof globalThis & {
184
+ __ANGULAR_HMR_STYLE_UPDATE_MODE__?: boolean;
185
+ };
186
+
187
+ type StyleBatchEntry = { id: string; ok: boolean };
188
+
189
+ const styleUpdateBatch: StyleBatchEntry[] = [];
190
+
191
+ const beginStyleUpdateBatch = () => {
192
+ styleUpdateBatch.length = 0;
193
+ };
194
+
195
+ const endStyleUpdateBatch = (): StyleBatchEntry[] => {
196
+ const out = styleUpdateBatch.slice();
197
+ styleUpdateBatch.length = 0;
198
+
199
+ return out;
200
+ };
201
+
148
202
  const register = (id: string, ctor: unknown) => {
149
203
  if (!id || !isComponentCtor(ctor)) return;
150
204
  if (!componentRegistry.has(id)) {
@@ -154,6 +208,34 @@ const register = (id: string, ctor: unknown) => {
154
208
  registeredAt: Date.now(),
155
209
  updateCount: 0
156
210
  });
211
+
212
+ return;
213
+ }
214
+
215
+ // Already registered. If we're inside an HMR style-update or
216
+ // template-update window, route this re-registration's new ctor
217
+ // through the appropriate surgical patcher. The per-file
218
+ // auto-registration block is the only place to intercept new ctors
219
+ // for CHILD components — the page chunk's `export *` only re-exports
220
+ // the page's own module.
221
+ const styleScope = globalThis as StyleUpdateMode;
222
+ if (styleScope.__ANGULAR_HMR_STYLE_UPDATE_MODE__) {
223
+ const ok = applyStyleUpdate(id, ctor);
224
+ styleUpdateBatch.push({ id, ok });
225
+
226
+ return;
227
+ }
228
+ const tmplScope = globalThis as TemplateUpdateMode;
229
+ if (tmplScope.__ANGULAR_HMR_TEMPLATE_UPDATE_MODE__) {
230
+ const ok = applyTemplateUpdate(id, ctor);
231
+ templateUpdateBatch.push({ id, ok });
232
+
233
+ return;
234
+ }
235
+ const svcScope = globalThis as ServiceUpdateMode;
236
+ if (svcScope.__ANGULAR_HMR_SERVICE_UPDATE_MODE__) {
237
+ const ok = applyServiceUpdate(id, ctor);
238
+ serviceUpdateBatch.push({ id, ok });
157
239
  }
158
240
  };
159
241
 
@@ -267,6 +349,383 @@ const markPatchedDirty = (ctor: ComponentCtor) => {
267
349
  }
268
350
  };
269
351
 
352
+ /* Component-style HMR — swaps `ɵcmp.styles` and replaces matching
353
+ * `<style>` tags in the document so the visible page reflects the new
354
+ * CSS without a re-bootstrap.
355
+ *
356
+ * Why this is safe with Emulated encapsulation (the default): Angular's
357
+ * compiler rewrites the CSS at build time, prefixing every selector
358
+ * with `[_ngcontent-c<scopeId>]`. The scope ID is deterministic per
359
+ * component def — the same source file produces the same scope ID
360
+ * across rebuilds — so the rewritten DOM still matches the new CSS.
361
+ * We only need to update the style *content*; the elements wearing
362
+ * `_ngcontent-c<scopeId>` attributes are still on the page from the
363
+ * initial bootstrap.
364
+ *
365
+ * ShadowDOM encapsulation (3) is not yet handled — each component
366
+ * instance has its own shadow root with its own style tags, requiring
367
+ * a per-instance walk. Falls through to reboot for now.
368
+ *
369
+ * The matching strategy: walk every `<style>` tag in `document.head`
370
+ * and `document.body`, find ones whose `textContent` exactly matches a
371
+ * string in the OLD `ɵcmp.styles` array, and replace it with the
372
+ * corresponding string from the NEW array. Equal-length arrays only —
373
+ * adding or removing a `styleUrl` entry triggers a reboot.
374
+ *
375
+ * Returns true on full success, false if we couldn't safely apply
376
+ * (length mismatch, ShadowDOM, missing styles array, or any old
377
+ * style had no DOM match — meaning we'd leave the page in a partially
378
+ * updated state). */
379
+
380
+ const SHADOW_DOM_ENCAPSULATION = 3;
381
+
382
+ type StyleHost = {
383
+ host: ParentNode;
384
+ tags: HTMLStyleElement[];
385
+ };
386
+
387
+ const collectStyleHosts = (): StyleHost[] => {
388
+ const hosts: StyleHost[] = [];
389
+ const headTags = Array.from(
390
+ document.head.querySelectorAll('style')
391
+ ) as HTMLStyleElement[];
392
+ const bodyTags = Array.from(
393
+ document.body.querySelectorAll('style')
394
+ ) as HTMLStyleElement[];
395
+ if (headTags.length > 0)
396
+ hosts.push({ host: document.head, tags: headTags });
397
+ if (bodyTags.length > 0)
398
+ hosts.push({ host: document.body, tags: bodyTags });
399
+
400
+ return hosts;
401
+ };
402
+
403
+ const findStyleTagByContent = (
404
+ hosts: StyleHost[],
405
+ content: string,
406
+ consumed: Set<HTMLStyleElement>
407
+ ): HTMLStyleElement | null => {
408
+ for (const { tags } of hosts) {
409
+ for (const tag of tags) {
410
+ if (consumed.has(tag)) continue;
411
+ if (tag.textContent === content) return tag;
412
+ }
413
+ }
414
+
415
+ return null;
416
+ };
417
+
418
+ const applyStyleUpdate = (id: string, newCtor: unknown) => {
419
+ if (!isComponentCtor(newCtor)) return false;
420
+
421
+ const entry = componentRegistry.get(id);
422
+ if (!entry) {
423
+ // First time we've seen this component — register it but no styles
424
+ // to swap yet. The next edit will pick up the now-registered ctor.
425
+ register(id, newCtor);
426
+
427
+ return true;
428
+ }
429
+
430
+ const { liveCtor } = entry;
431
+ if (liveCtor === newCtor) return true;
432
+
433
+ const liveCmp = liveCtor.ɵcmp;
434
+ const newCmp = newCtor.ɵcmp;
435
+ if (!liveCmp || !newCmp) return false;
436
+
437
+ if (
438
+ liveCmp.encapsulation === SHADOW_DOM_ENCAPSULATION ||
439
+ newCmp.encapsulation === SHADOW_DOM_ENCAPSULATION
440
+ ) {
441
+ // Shadow DOM scopes styles per-instance — out of scope for v1.
442
+ return false;
443
+ }
444
+
445
+ const oldStyles = liveCmp.styles;
446
+ const nextStyles = newCmp.styles;
447
+ if (!Array.isArray(oldStyles) || !Array.isArray(nextStyles)) return false;
448
+ if (oldStyles.length !== nextStyles.length) return false;
449
+ if (oldStyles.length === 0) {
450
+ // No styles to swap, no work to do — succeed trivially.
451
+ liveCmp.styles = nextStyles;
452
+
453
+ return true;
454
+ }
455
+
456
+ const hosts = collectStyleHosts();
457
+ const consumed = new Set<HTMLStyleElement>();
458
+ const matches: { tag: HTMLStyleElement; nextContent: string }[] = [];
459
+
460
+ for (let i = 0; i < oldStyles.length; i++) {
461
+ const oldContent = oldStyles[i] ?? '';
462
+ const nextContent = nextStyles[i] ?? '';
463
+ if (oldContent === nextContent) continue;
464
+ const tag = findStyleTagByContent(hosts, oldContent, consumed);
465
+ if (!tag) {
466
+ // Couldn't locate one of the live <style> tags — fall through
467
+ // to reboot rather than leaving the page in a half-updated
468
+ // state.
469
+ return false;
470
+ }
471
+ consumed.add(tag);
472
+ matches.push({ tag, nextContent });
473
+ }
474
+
475
+ // Only mutate after we've verified we can update every diffed style.
476
+ for (const { tag, nextContent } of matches) {
477
+ tag.textContent = nextContent;
478
+ }
479
+ liveCmp.styles = nextStyles;
480
+
481
+ updateCounter.value++;
482
+ entry.updateCount++;
483
+ entry.registeredAt = Date.now();
484
+
485
+ return true;
486
+ };
487
+
488
+ /* Template HMR — surgical swap of the template-related fields on a
489
+ * registered component's `ɵcmp` so the live instance re-renders with
490
+ * the new template WITHOUT re-instantiating. Inputs, outputs, host
491
+ * bindings, providers, and lifecycle hooks live on the class
492
+ * prototype + ɵcmp, and we leave those alone — only the template
493
+ * factory and the slot counts/queries that depend on it are replaced.
494
+ *
495
+ * Why a defined list of fields and not a full `ɵcmp` swap: a wholesale
496
+ * `Object.assign(liveCmp, newCmp)` would also overwrite `providers /
497
+ * providersResolver` and other class-level metadata. Those changes
498
+ * already require a full reboot (the existing fast-path handler in
499
+ * `angular.ts` checks `hasProviderChanges` and bails). For a pure
500
+ * template edit, restricting the patch to the template subgraph
501
+ * keeps live instances on the same DI tokens, queryList references,
502
+ * input bindings, etc. — only the rendered output changes.
503
+ *
504
+ * After the swap, the component's TView (the cached view layout) is
505
+ * stale because slot counts may have changed. Angular regenerates the
506
+ * TView lazily on the first re-render, but only if the existing one
507
+ * is invalidated — which happens automatically when we walk the live
508
+ * instances and call `applyChanges`. The same `markPatchedDirty`
509
+ * helper used by `applyUpdate` covers OnPush views too. */
510
+
511
+ const TEMPLATE_PATCH_FIELDS = [
512
+ 'template',
513
+ 'consts',
514
+ 'decls',
515
+ 'vars',
516
+ 'viewQuery',
517
+ 'contentQueries',
518
+ 'ngContentSelectors',
519
+ 'dependencies',
520
+ 'hostBindings',
521
+ 'hostVars',
522
+ 'hostAttrs',
523
+ 'inputs',
524
+ 'outputs'
525
+ ] as const;
526
+
527
+ const applyTemplateUpdate = (id: string, newCtor: unknown) => {
528
+ if (!isComponentCtor(newCtor)) return false;
529
+
530
+ const entry = componentRegistry.get(id);
531
+ if (!entry) {
532
+ register(id, newCtor);
533
+
534
+ return true;
535
+ }
536
+
537
+ const { liveCtor } = entry;
538
+ if (liveCtor === newCtor) return true;
539
+
540
+ const liveCmp = liveCtor.ɵcmp as Record<string, unknown> | undefined;
541
+ const nextCmp = newCtor.ɵcmp as Record<string, unknown> | undefined;
542
+ if (!liveCmp || !nextCmp) return false;
543
+
544
+ // If providers changed, this isn't a pure template edit anymore —
545
+ // fall back to reboot via the caller.
546
+ if (hasProviderChanges(liveCtor, newCtor)) return false;
547
+
548
+ for (const field of TEMPLATE_PATCH_FIELDS) {
549
+ if (Object.prototype.hasOwnProperty.call(nextCmp, field)) {
550
+ liveCmp[field] = nextCmp[field];
551
+ }
552
+ }
553
+
554
+ pendingFastPatchRefresh.add(liveCtor);
555
+ updateCounter.value++;
556
+ entry.updateCount++;
557
+ entry.registeredAt = Date.now();
558
+
559
+ return true;
560
+ };
561
+
562
+ type TemplateUpdateMode = typeof globalThis & {
563
+ __ANGULAR_HMR_TEMPLATE_UPDATE_MODE__?: boolean;
564
+ };
565
+
566
+ const templateUpdateBatch: StyleBatchEntry[] = [];
567
+
568
+ const beginTemplateUpdateBatch = () => {
569
+ templateUpdateBatch.length = 0;
570
+ };
571
+
572
+ const endTemplateUpdateBatch = (): StyleBatchEntry[] => {
573
+ const out = templateUpdateBatch.slice();
574
+ templateUpdateBatch.length = 0;
575
+
576
+ return out;
577
+ };
578
+
579
+ /* Service HMR — Level 3 hybrid:
580
+ * 1. Always swap prototype methods on the live ctor. Reaches every
581
+ * live instance (singletons + transient injectees) because they
582
+ * all share the same prototype.
583
+ * 2. If the live singleton is reachable via the root injector,
584
+ * attempt to instantiate a donor with the new ctor and copy any
585
+ * OWN PROPERTIES that the live singleton is missing — this picks
586
+ * up new class-field initializers without overwriting accumulated
587
+ * runtime state. Donor instantiation is best-effort: services
588
+ * using `inject()` outside of an injection context will throw,
589
+ * and we just skip the field merge in that case (the prototype
590
+ * swap still applies, so method changes take effect).
591
+ * 3. The classifier only routes here for services with NO
592
+ * side-effecting calls in the constructor / field initializers
593
+ * (no `subscribe / setInterval / addEventListener / effect /
594
+ * new Worker / new EventSource / etc.`). Anything that touches
595
+ * external state at construction time falls through to reboot
596
+ * via the server-side classification, never reaching this code
597
+ * path. */
598
+
599
+ type AppRefWithInjector = {
600
+ injector?: { get?: (token: unknown, notFoundValue?: unknown) => unknown };
601
+ };
602
+
603
+ const getRootInjector = (): {
604
+ get: (token: unknown, notFoundValue?: unknown) => unknown;
605
+ } | null => {
606
+ const app = window.__ANGULAR_APP__ as AppRefWithInjector | null;
607
+ if (!app || !app.injector || typeof app.injector.get !== 'function') {
608
+ return null;
609
+ }
610
+
611
+ return app.injector as {
612
+ get: (token: unknown, notFoundValue?: unknown) => unknown;
613
+ };
614
+ };
615
+
616
+ const swapPrototypeMethods = (
617
+ liveCtor: ComponentCtor,
618
+ newCtor: ComponentCtor
619
+ ) => {
620
+ const newProto = newCtor.prototype as Record<string, unknown>;
621
+ const liveProto = liveCtor.prototype as Record<string, unknown>;
622
+ Object.getOwnPropertyNames(newProto).forEach((prop) => {
623
+ if (prop === 'constructor') return;
624
+ try {
625
+ const desc = Object.getOwnPropertyDescriptor(newProto, prop);
626
+ if (desc) Object.defineProperty(liveProto, prop, desc);
627
+ } catch {
628
+ /* non-configurable property — skip */
629
+ }
630
+ });
631
+ };
632
+
633
+ const tryInstantiateServiceDonor = (newCtor: ComponentCtor): unknown | null => {
634
+ try {
635
+ // `new newCtor()` with no args. Works for services with no
636
+ // constructor params and no `inject()` calls at field-init time.
637
+ // Anything more sophisticated (services that use `inject()`
638
+ // outside an injection context) throws here and we fall back to
639
+ // prototype-only swap.
640
+ return Reflect.construct(newCtor as unknown as new () => unknown, []);
641
+ } catch {
642
+ return null;
643
+ }
644
+ };
645
+
646
+ const mergeMissingFields = (
647
+ liveInstance: Record<string, unknown>,
648
+ donor: Record<string, unknown>
649
+ ) => {
650
+ let merged = 0;
651
+ Object.getOwnPropertyNames(donor).forEach((prop) => {
652
+ if (Object.prototype.hasOwnProperty.call(liveInstance, prop)) return;
653
+ try {
654
+ const desc = Object.getOwnPropertyDescriptor(donor, prop);
655
+ if (desc) {
656
+ Object.defineProperty(liveInstance, prop, desc);
657
+ merged++;
658
+ }
659
+ } catch {
660
+ /* defining the property failed — skip */
661
+ }
662
+ });
663
+
664
+ return merged;
665
+ };
666
+
667
+ const applyServiceUpdate = (id: string, newCtor: unknown) => {
668
+ if (!isComponentCtor(newCtor)) return false;
669
+
670
+ const entry = componentRegistry.get(id);
671
+ if (!entry) {
672
+ register(id, newCtor);
673
+
674
+ return true;
675
+ }
676
+
677
+ const { liveCtor } = entry;
678
+ if (liveCtor === newCtor) return true;
679
+
680
+ // Method swap — reaches every live instance.
681
+ swapPrototypeMethods(liveCtor, newCtor);
682
+
683
+ // Best-effort field merge on the live singleton.
684
+ const injector = getRootInjector();
685
+ if (injector) {
686
+ try {
687
+ const liveInstance = injector.get(liveCtor, null) as Record<
688
+ string,
689
+ unknown
690
+ > | null;
691
+ if (liveInstance) {
692
+ const donor = tryInstantiateServiceDonor(newCtor) as Record<
693
+ string,
694
+ unknown
695
+ > | null;
696
+ if (donor) mergeMissingFields(liveInstance, donor);
697
+ }
698
+ } catch {
699
+ /* injector lookup failed — service may not be `providedIn:
700
+ "root"`, or the type-token mismatched. Prototype swap is
701
+ already applied, so methods take effect either way. */
702
+ }
703
+ }
704
+
705
+ updateCounter.value++;
706
+ entry.updateCount++;
707
+ entry.registeredAt = Date.now();
708
+
709
+ return true;
710
+ };
711
+
712
+ type ServiceUpdateMode = typeof globalThis & {
713
+ __ANGULAR_HMR_SERVICE_UPDATE_MODE__?: boolean;
714
+ };
715
+
716
+ const serviceUpdateBatch: StyleBatchEntry[] = [];
717
+
718
+ const beginServiceUpdateBatch = () => {
719
+ serviceUpdateBatch.length = 0;
720
+ };
721
+
722
+ const endServiceUpdateBatch = (): StyleBatchEntry[] => {
723
+ const out = serviceUpdateBatch.slice();
724
+ serviceUpdateBatch.length = 0;
725
+
726
+ return out;
727
+ };
728
+
270
729
  const applyUpdate = (id: string, newCtor: unknown) => {
271
730
  if (!isComponentCtor(newCtor)) return false;
272
731
 
@@ -402,7 +861,16 @@ const hasPageExportsChanged = (sourceId: string): boolean => {
402
861
  export const installAngularHMRRuntime = () => {
403
862
  if (typeof window === 'undefined') return;
404
863
  window.__ANGULAR_HMR__ = {
864
+ applyServiceUpdate,
865
+ applyStyleUpdate,
866
+ applyTemplateUpdate,
405
867
  applyUpdate,
868
+ beginServiceUpdateBatch,
869
+ beginStyleUpdateBatch,
870
+ beginTemplateUpdateBatch,
871
+ endServiceUpdateBatch,
872
+ endStyleUpdateBatch,
873
+ endTemplateUpdateBatch,
406
874
  getStats: getAngularHmrStats,
407
875
  hasPageExportsChanged,
408
876
  recordPageExports,
@@ -0,0 +1,150 @@
1
+ import type {} from '../../types/globals';
2
+ /* HMR notification toast — bottom-right corner, fades in for ~2.5s.
3
+ *
4
+ * Used when the Angular HMR client falls through to a full reboot, so
5
+ * the developer can SEE why a save triggered a reboot instead of
6
+ * silently watching the splash transition. The reason string comes
7
+ * from the server-side classifier (`src/dev/angular/editTypeDetection`),
8
+ * surfaced via the `reason` field on the HMR wire message.
9
+ *
10
+ * Mounts a single shared container the first time it's called and
11
+ * stacks toasts inside it. Each toast removes itself after the visible
12
+ * window expires; the container stays mounted for the session. */
13
+
14
+ const CONTAINER_ID = '__abs_hmr_toast_container__';
15
+ const VISIBLE_DURATION_MS = 2500;
16
+ const FADE_MS = 220;
17
+
18
+ const ensureContainer = (): HTMLDivElement => {
19
+ const existing = document.getElementById(
20
+ CONTAINER_ID
21
+ ) as HTMLDivElement | null;
22
+ if (existing) return existing;
23
+
24
+ const container = document.createElement('div');
25
+ container.id = CONTAINER_ID;
26
+ Object.assign(container.style, {
27
+ position: 'fixed',
28
+ bottom: '16px',
29
+ right: '16px',
30
+ display: 'flex',
31
+ flexDirection: 'column',
32
+ gap: '8px',
33
+ zIndex: '2147483646',
34
+ pointerEvents: 'none',
35
+ fontFamily:
36
+ 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
37
+ fontSize: '12px',
38
+ maxWidth: '420px'
39
+ });
40
+ document.body.appendChild(container);
41
+
42
+ return container;
43
+ };
44
+
45
+ const accentForType = (updateType: string | undefined): string => {
46
+ switch (updateType) {
47
+ case 'route':
48
+ return '#1d4ed8';
49
+ case 'service-with-side-effects':
50
+ return '#b45309';
51
+ case 'reboot':
52
+ default:
53
+ return '#dd0031';
54
+ }
55
+ };
56
+
57
+ export type HmrToastInput = {
58
+ updateType?: string;
59
+ reason?: string;
60
+ editSourceFile?: string;
61
+ };
62
+
63
+ export const showHmrToast = ({
64
+ updateType,
65
+ reason,
66
+ editSourceFile
67
+ }: HmrToastInput) => {
68
+ if (typeof document === 'undefined') return;
69
+ const container = ensureContainer();
70
+
71
+ const toast = document.createElement('div');
72
+ const accent = accentForType(updateType);
73
+ Object.assign(toast.style, {
74
+ background: 'rgba(15, 17, 22, 0.94)',
75
+ color: '#f8fafc',
76
+ borderLeft: `3px solid ${accent}`,
77
+ padding: '8px 12px',
78
+ borderRadius: '6px',
79
+ boxShadow: '0 6px 24px rgba(0, 0, 0, 0.35)',
80
+ opacity: '0',
81
+ transform: 'translateY(6px)',
82
+ transition: `opacity ${FADE_MS}ms ease, transform ${FADE_MS}ms ease`,
83
+ pointerEvents: 'auto',
84
+ whiteSpace: 'nowrap',
85
+ overflow: 'hidden',
86
+ textOverflow: 'ellipsis',
87
+ maxWidth: '420px'
88
+ });
89
+
90
+ const label = document.createElement('div');
91
+ Object.assign(label.style, {
92
+ color: accent,
93
+ fontWeight: '600',
94
+ marginBottom: '2px',
95
+ letterSpacing: '0.02em'
96
+ });
97
+ label.textContent = `HMR reboot — ${updateType ?? 'unknown'}`;
98
+ toast.appendChild(label);
99
+
100
+ const body = document.createElement('div');
101
+ Object.assign(body.style, {
102
+ color: '#cbd5e1',
103
+ whiteSpace: 'normal',
104
+ wordBreak: 'break-word'
105
+ });
106
+ body.textContent = reason ?? '(no reason given)';
107
+ toast.appendChild(body);
108
+
109
+ if (editSourceFile) {
110
+ const path = document.createElement('div');
111
+ Object.assign(path.style, {
112
+ color: '#64748b',
113
+ marginTop: '2px',
114
+ fontSize: '11px',
115
+ whiteSpace: 'nowrap',
116
+ overflow: 'hidden',
117
+ textOverflow: 'ellipsis'
118
+ });
119
+ // Format like the server logger does: relative + leading slash.
120
+ const cwdLike = editSourceFile.replace(/^.*?(\/src\/|\/pages\/)/, '$1');
121
+ path.textContent = cwdLike;
122
+ toast.appendChild(path);
123
+ }
124
+
125
+ container.appendChild(toast);
126
+
127
+ // Trigger CSS transition by deferring to the next paint.
128
+ requestAnimationFrame(() => {
129
+ toast.style.opacity = '1';
130
+ toast.style.transform = 'translateY(0)';
131
+ });
132
+
133
+ const removeAt = window.setTimeout(() => {
134
+ toast.style.opacity = '0';
135
+ toast.style.transform = 'translateY(6px)';
136
+ window.setTimeout(() => {
137
+ if (toast.parentNode) toast.parentNode.removeChild(toast);
138
+ }, FADE_MS);
139
+ }, VISIBLE_DURATION_MS);
140
+
141
+ // If the user clicks the toast, dismiss it immediately.
142
+ toast.addEventListener('click', () => {
143
+ window.clearTimeout(removeAt);
144
+ toast.style.opacity = '0';
145
+ toast.style.transform = 'translateY(6px)';
146
+ window.setTimeout(() => {
147
+ if (toast.parentNode) toast.parentNode.removeChild(toast);
148
+ }, FADE_MS);
149
+ });
150
+ };