@pyreon/server 0.15.0 → 0.18.0

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.
@@ -4,7 +4,7 @@
4
4
 
5
5
  import type { ComponentFn } from '@pyreon/core'
6
6
  import { h } from '@pyreon/core'
7
- import { hydrateIslands, startClient } from '../client'
7
+ import { hydrateIslands, hydrateIslandsAuto, startClient } from '../client'
8
8
 
9
9
  // ─── startClient ────────────────────────────────────────────────────────────
10
10
 
@@ -574,4 +574,918 @@ describe('hydrateIslands', () => {
574
574
  cleanup()
575
575
  window.matchMedia = origMatchMedia
576
576
  })
577
+
578
+ test('marks nested islands with data-island-error="nested" and skips them', () => {
579
+ document.body.innerHTML = `
580
+ <pyreon-island data-component="Outer" data-props="{}">
581
+ <div>
582
+ <pyreon-island data-component="Inner" data-props="{}"></pyreon-island>
583
+ </div>
584
+ </pyreon-island>
585
+ `
586
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
587
+
588
+ const Outer: ComponentFn = () => h('div', null, 'outer')
589
+ const Inner: ComponentFn = () => h('div', null, 'inner')
590
+
591
+ const cleanup = hydrateIslands({
592
+ Outer: () => Promise.resolve({ default: Outer }),
593
+ Inner: () => Promise.resolve({ default: Inner }),
594
+ })
595
+
596
+ const inner = document.querySelector('pyreon-island[data-component="Inner"]')!
597
+ expect(inner.getAttribute('data-island-error')).toBe('nested')
598
+ expect(errorSpy).toHaveBeenCalledWith(
599
+ expect.stringContaining('nested inside another <pyreon-island>'),
600
+ )
601
+ // Outer is not nested → not marked
602
+ const outer = document.querySelector('pyreon-island[data-component="Outer"]')!
603
+ expect(outer.getAttribute('data-island-error')).toBeNull()
604
+
605
+ errorSpy.mockRestore()
606
+ cleanup()
607
+ })
608
+
609
+ test('marks islands with no registered loader as data-island-error="no-loader"', () => {
610
+ document.body.innerHTML =
611
+ '<pyreon-island data-component="Missing" data-props="{}"></pyreon-island>'
612
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
613
+
614
+ const cleanup = hydrateIslands({})
615
+ const el = document.querySelector('pyreon-island')!
616
+ expect(el.getAttribute('data-island-error')).toBe('no-loader')
617
+
618
+ warnSpy.mockRestore()
619
+ cleanup()
620
+ })
621
+
622
+ test('marks islands with invalid props JSON as data-island-error="invalid-props"', async () => {
623
+ document.body.innerHTML =
624
+ '<pyreon-island data-component="Bad" data-props="not json"></pyreon-island>'
625
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
626
+
627
+ const Comp: ComponentFn = () => h('div', null)
628
+ const cleanup = hydrateIslands({
629
+ Bad: () => Promise.resolve({ default: Comp }),
630
+ })
631
+ await new Promise((r) => setTimeout(r, 50))
632
+
633
+ const el = document.querySelector('pyreon-island')!
634
+ expect(el.getAttribute('data-island-error')).toBe('invalid-props')
635
+
636
+ errorSpy.mockRestore()
637
+ cleanup()
638
+ })
639
+
640
+ test('prefetch=idle: pre-warms loader during idle BEFORE hydration trigger', async () => {
641
+ document.body.innerHTML =
642
+ '<pyreon-island data-component="PreIdle" data-hydrate="visible" data-prefetch="idle" data-props="{}"></pyreon-island>'
643
+
644
+ let loaderCalls = 0
645
+ const Comp: ComponentFn = () => h('div', null, 'pre-idle')
646
+
647
+ // Mock IntersectionObserver so it does NOT auto-fire on observe — this
648
+ // isolates the prefetch path from hydration.
649
+ const origIO = globalThis.IntersectionObserver
650
+ globalThis.IntersectionObserver = class {
651
+ observe() {}
652
+ disconnect() {}
653
+ unobserve() {}
654
+ takeRecords() {
655
+ return []
656
+ }
657
+ get root() {
658
+ return null
659
+ }
660
+ get rootMargin() {
661
+ return ''
662
+ }
663
+ get thresholds() {
664
+ return []
665
+ }
666
+ } as unknown as typeof IntersectionObserver
667
+
668
+ const cleanup = hydrateIslands({
669
+ PreIdle: () => {
670
+ loaderCalls++
671
+ return Promise.resolve({ default: Comp })
672
+ },
673
+ })
674
+ // Wait long enough for requestIdleCallback (or its setTimeout fallback)
675
+ // to fire — happy-dom doesn't ship requestIdleCallback so the 200ms
676
+ // setTimeout fallback is what we exercise here.
677
+ await new Promise((r) => setTimeout(r, 300))
678
+
679
+ expect(loaderCalls).toBe(1)
680
+ cleanup()
681
+ globalThis.IntersectionObserver = origIO
682
+ })
683
+
684
+ test('prefetch=visible: pre-warms loader via IntersectionObserver before hydration', async () => {
685
+ document.body.innerHTML =
686
+ '<pyreon-island data-component="PreVis" data-hydrate="media((max-width: 1px))" data-prefetch="visible" data-props="{}"></pyreon-island>'
687
+
688
+ let loaderCalls = 0
689
+ const Comp: ComponentFn = () => h('div', null, 'pre-vis')
690
+
691
+ // Two IntersectionObserver instances are created when both prefetch=visible
692
+ // AND hydrate=visible — but here hydrate=media(unmatched), so only ONE
693
+ // IntersectionObserver instance is created (the prefetch one). Fire its
694
+ // callback synchronously on observe().
695
+ const origIO = globalThis.IntersectionObserver
696
+ globalThis.IntersectionObserver = class {
697
+ private cb: IntersectionObserverCallback
698
+ constructor(cb: IntersectionObserverCallback) {
699
+ this.cb = cb
700
+ }
701
+ observe(_el: Element) {
702
+ this.cb(
703
+ [{ isIntersecting: true } as IntersectionObserverEntry],
704
+ this as unknown as IntersectionObserver,
705
+ )
706
+ }
707
+ disconnect() {}
708
+ unobserve() {}
709
+ takeRecords() {
710
+ return []
711
+ }
712
+ get root() {
713
+ return null
714
+ }
715
+ get rootMargin() {
716
+ return ''
717
+ }
718
+ get thresholds() {
719
+ return []
720
+ }
721
+ } as unknown as typeof IntersectionObserver
722
+
723
+ // Mock matchMedia → matches: false so hydration does NOT fire.
724
+ const origMatchMedia = window.matchMedia
725
+ window.matchMedia = (q: string) =>
726
+ ({
727
+ matches: false,
728
+ media: q,
729
+ onchange: null,
730
+ addEventListener: () => {},
731
+ removeEventListener: () => {},
732
+ addListener: () => {},
733
+ removeListener: () => {},
734
+ dispatchEvent: () => true,
735
+ }) as unknown as MediaQueryList
736
+
737
+ const cleanup = hydrateIslands({
738
+ PreVis: () => {
739
+ loaderCalls++
740
+ return Promise.resolve({ default: Comp })
741
+ },
742
+ })
743
+ await new Promise((r) => setTimeout(r, 50))
744
+
745
+ // Loader called exactly once via the prefetch IntersectionObserver path.
746
+ // hydrate=media doesn't match → no second loader call from hydration.
747
+ expect(loaderCalls).toBe(1)
748
+
749
+ cleanup()
750
+ globalThis.IntersectionObserver = origIO
751
+ window.matchMedia = origMatchMedia
752
+ })
753
+
754
+ test('prefetch=visible falls back to immediate prime when IntersectionObserver is missing', async () => {
755
+ document.body.innerHTML =
756
+ '<pyreon-island data-component="PreFallback" data-hydrate="media((max-width: 1px))" data-prefetch="visible" data-props="{}"></pyreon-island>'
757
+
758
+ let loaderCalls = 0
759
+ const Comp: ComponentFn = () => h('div', null, 'fb')
760
+
761
+ const origIO = (window as unknown as Record<string, unknown>).IntersectionObserver
762
+ delete (window as unknown as Record<string, unknown>).IntersectionObserver
763
+ const origMatchMedia = window.matchMedia
764
+ window.matchMedia = (q: string) =>
765
+ ({
766
+ matches: false,
767
+ media: q,
768
+ onchange: null,
769
+ addEventListener: () => {},
770
+ removeEventListener: () => {},
771
+ addListener: () => {},
772
+ removeListener: () => {},
773
+ dispatchEvent: () => true,
774
+ }) as unknown as MediaQueryList
775
+
776
+ const cleanup = hydrateIslands({
777
+ PreFallback: () => {
778
+ loaderCalls++
779
+ return Promise.resolve({ default: Comp })
780
+ },
781
+ })
782
+ await new Promise((r) => setTimeout(r, 50))
783
+ expect(loaderCalls).toBe(1)
784
+
785
+ cleanup()
786
+ ;(window as unknown as Record<string, unknown>).IntersectionObserver = origIO
787
+ window.matchMedia = origMatchMedia
788
+ })
789
+
790
+ test('prefetch absent (default none): does NOT call loader before hydration trigger', async () => {
791
+ document.body.innerHTML =
792
+ '<pyreon-island data-component="NoPre" data-hydrate="visible" data-props="{}"></pyreon-island>'
793
+
794
+ let loaderCalls = 0
795
+ const Comp: ComponentFn = () => h('div', null)
796
+
797
+ // Block IntersectionObserver from auto-firing → hydration won't run either.
798
+ const origIO = globalThis.IntersectionObserver
799
+ globalThis.IntersectionObserver = class {
800
+ observe() {}
801
+ disconnect() {}
802
+ unobserve() {}
803
+ takeRecords() {
804
+ return []
805
+ }
806
+ get root() {
807
+ return null
808
+ }
809
+ get rootMargin() {
810
+ return ''
811
+ }
812
+ get thresholds() {
813
+ return []
814
+ }
815
+ } as unknown as typeof IntersectionObserver
816
+
817
+ const cleanup = hydrateIslands({
818
+ NoPre: () => {
819
+ loaderCalls++
820
+ return Promise.resolve({ default: Comp })
821
+ },
822
+ })
823
+ await new Promise((r) => setTimeout(r, 300))
824
+ expect(loaderCalls).toBe(0)
825
+
826
+ cleanup()
827
+ globalThis.IntersectionObserver = origIO
828
+ })
829
+
830
+ test('prefetch loader rejection: caught silently when hydration NEVER fires (no unhandled rejection)', async () => {
831
+ // Contract under test: prefetch is fire-and-forget. If the loader rejects
832
+ // AND no subsequent hydration call ever runs (e.g. media-query strategy
833
+ // that never matches, or user navigates away pre-scroll), the rejection
834
+ // MUST NOT bubble up as `unhandledrejection`. Hydration's own `await
835
+ // loader()` would otherwise consume the rejection via JS's import-promise
836
+ // dedup — but in this scenario hydration never fires, so prefetch's own
837
+ // `.catch(() => {})` is the ONLY handler, and removing it would surface
838
+ // the unhandled rejection.
839
+ //
840
+ // To isolate prefetch from hydration: pair `prefetch: 'idle'` with a
841
+ // media query that can never match (max-width: 1px), so the hydration
842
+ // path stays parked forever and prefetch's catch handler is the only
843
+ // protection.
844
+ document.body.innerHTML =
845
+ '<pyreon-island data-component="RejectPre" data-hydrate="media((max-width: 1px))" data-prefetch="idle" data-props="{}"></pyreon-island>'
846
+
847
+ let prefetchCalls = 0
848
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
849
+
850
+ // Mock matchMedia → never matches, so hydration's mql.addEventListener
851
+ // path waits forever and never calls loader().
852
+ const origMatchMedia = window.matchMedia
853
+ window.matchMedia = (q: string) =>
854
+ ({
855
+ matches: false,
856
+ media: q,
857
+ onchange: null,
858
+ addEventListener: () => {},
859
+ removeEventListener: () => {},
860
+ addListener: () => {},
861
+ removeListener: () => {},
862
+ dispatchEvent: () => true,
863
+ }) as unknown as MediaQueryList
864
+
865
+ // Listen on BOTH `window.unhandledrejection` (browser env) and Node's
866
+ // `process.on('unhandledRejection')` — vitest's happy-dom env doesn't
867
+ // reliably fire the browser event, but Node-level rejection tracking
868
+ // does fire (vitest's runner is Node + happy-dom shims).
869
+ let unhandled = false
870
+ const onUnhandled = (event: PromiseRejectionEvent) => {
871
+ unhandled = true
872
+ event.preventDefault?.()
873
+ }
874
+ const onProcUnhandled = (reason: unknown) => {
875
+ // Only flag rejections that came from OUR loader (not test runner internals).
876
+ if (reason instanceof Error && reason.message === 'boom') {
877
+ unhandled = true
878
+ }
879
+ }
880
+ window.addEventListener('unhandledrejection', onUnhandled)
881
+ process.on('unhandledRejection', onProcUnhandled)
882
+
883
+ const cleanup = hydrateIslands({
884
+ RejectPre: () => {
885
+ prefetchCalls++
886
+ return Promise.reject(new Error('boom'))
887
+ },
888
+ })
889
+
890
+ // Wait long enough for: idle-fallback setTimeout(200), microtask drain,
891
+ // and Node's unhandledRejection tick (deferred to next tick by spec).
892
+ await new Promise((r) => setTimeout(r, 400))
893
+ // Force one more macrotask so Node's unhandledRejection has fired.
894
+ await new Promise((r) => setImmediate(r))
895
+
896
+ // Prefetch fired (proves the rejection actually reached the handler).
897
+ expect(prefetchCalls).toBe(1)
898
+ // Critical assertion: no unhandled rejection. This is what
899
+ // `loader().catch(() => {})` protects.
900
+ expect(unhandled).toBe(false)
901
+
902
+ cleanup()
903
+ window.matchMedia = origMatchMedia
904
+ window.removeEventListener('unhandledrejection', onUnhandled)
905
+ process.off('unhandledRejection', onProcUnhandled)
906
+ errorSpy.mockRestore()
907
+ })
908
+
909
+ test('cleanup cancels pending prefetch before it fires', async () => {
910
+ document.body.innerHTML =
911
+ '<pyreon-island data-component="CancelPre" data-hydrate="visible" data-prefetch="idle" data-props="{}"></pyreon-island>'
912
+
913
+ let loaderCalls = 0
914
+ const Comp: ComponentFn = () => h('div', null)
915
+
916
+ const origIO = globalThis.IntersectionObserver
917
+ globalThis.IntersectionObserver = class {
918
+ observe() {}
919
+ disconnect() {}
920
+ unobserve() {}
921
+ takeRecords() {
922
+ return []
923
+ }
924
+ get root() {
925
+ return null
926
+ }
927
+ get rootMargin() {
928
+ return ''
929
+ }
930
+ get thresholds() {
931
+ return []
932
+ }
933
+ } as unknown as typeof IntersectionObserver
934
+
935
+ const cleanup = hydrateIslands({
936
+ CancelPre: () => {
937
+ loaderCalls++
938
+ return Promise.resolve({ default: Comp })
939
+ },
940
+ })
941
+ // Cancel BEFORE the 200ms setTimeout fallback fires.
942
+ cleanup()
943
+ await new Promise((r) => setTimeout(r, 300))
944
+ expect(loaderCalls).toBe(0)
945
+
946
+ globalThis.IntersectionObserver = origIO
947
+ })
948
+
949
+ // ─── hydrateIslandsAuto tests ────────────────────────────────────────────
950
+
951
+ test('hydrateIslandsAuto: throws on disabled stub registry with actionable message', () => {
952
+ expect(() =>
953
+ hydrateIslandsAuto({
954
+ __pyreonIslandsEnabled: false,
955
+ __pyreonIslandRegistry: {},
956
+ }),
957
+ ).toThrow(/pyreon\({ islands: true }\)/)
958
+ })
959
+
960
+ test('hydrateIslandsAuto: forwards enabled registry to hydrateIslands', async () => {
961
+ document.body.innerHTML =
962
+ '<pyreon-island data-component="Auto" data-props="{}"></pyreon-island>'
963
+
964
+ let loaded = 0
965
+ const cleanup = hydrateIslandsAuto({
966
+ __pyreonIslandsEnabled: true,
967
+ __pyreonIslandRegistry: {
968
+ Auto: () => {
969
+ loaded++
970
+ return Promise.resolve({ default: () => h('div', null, 'auto') })
971
+ },
972
+ },
973
+ })
974
+
975
+ await new Promise((r) => setTimeout(r, 50))
976
+ expect(loaded).toBe(1)
977
+
978
+ cleanup()
979
+ })
980
+
981
+ // ─── requestIdleCallback path tests (happy-dom lacks it natively) ────────
982
+
983
+ test('hydrate=idle: uses requestIdleCallback when present + cancelIdleCallback on cleanup', async () => {
984
+ document.body.innerHTML =
985
+ '<pyreon-island data-component="Idle" data-hydrate="idle" data-props="{}"></pyreon-island>'
986
+
987
+ let scheduled: ((deadline?: unknown) => void) | null = null
988
+ let cancelledId: number | null = null
989
+ ;(window as unknown as Record<string, unknown>).requestIdleCallback = (cb: () => void) => {
990
+ scheduled = cb
991
+ return 42
992
+ }
993
+ ;(window as unknown as Record<string, unknown>).cancelIdleCallback = (id: number) => {
994
+ cancelledId = id
995
+ }
996
+
997
+ let loaded = 0
998
+ const cleanup = hydrateIslands({
999
+ Idle: () => {
1000
+ loaded++
1001
+ return Promise.resolve({ default: () => h('div', null, 'idle') })
1002
+ },
1003
+ })
1004
+
1005
+ expect(typeof scheduled).toBe('function')
1006
+ cleanup()
1007
+ expect(cancelledId).toBe(42)
1008
+ // After cancellation, even if the idle callback fires, the cancel guard skips the load.
1009
+ if (scheduled) (scheduled as () => void)()
1010
+ await new Promise((r) => setTimeout(r, 20))
1011
+ expect(loaded).toBe(0)
1012
+
1013
+ delete (window as unknown as Record<string, unknown>).requestIdleCallback
1014
+ delete (window as unknown as Record<string, unknown>).cancelIdleCallback
1015
+ })
1016
+
1017
+ test('prefetch=idle: uses requestIdleCallback when present + cancelIdleCallback on cleanup', async () => {
1018
+ document.body.innerHTML =
1019
+ '<pyreon-island data-component="PreIdleNative" data-hydrate="visible" data-prefetch="idle" data-props="{}"></pyreon-island>'
1020
+
1021
+ let scheduled: ((deadline?: unknown) => void) | null = null
1022
+ let cancelledId: number | null = null
1023
+ ;(window as unknown as Record<string, unknown>).requestIdleCallback = (cb: () => void) => {
1024
+ scheduled = cb
1025
+ return 99
1026
+ }
1027
+ ;(window as unknown as Record<string, unknown>).cancelIdleCallback = (id: number) => {
1028
+ cancelledId = id
1029
+ }
1030
+
1031
+ // Block IntersectionObserver from auto-firing.
1032
+ const origIO = globalThis.IntersectionObserver
1033
+ globalThis.IntersectionObserver = class {
1034
+ observe() {}
1035
+ disconnect() {}
1036
+ unobserve() {}
1037
+ takeRecords() {
1038
+ return []
1039
+ }
1040
+ get root() {
1041
+ return null
1042
+ }
1043
+ get rootMargin() {
1044
+ return ''
1045
+ }
1046
+ get thresholds() {
1047
+ return []
1048
+ }
1049
+ } as unknown as typeof IntersectionObserver
1050
+
1051
+ let loaded = 0
1052
+ const cleanup = hydrateIslands({
1053
+ PreIdleNative: () => {
1054
+ loaded++
1055
+ return Promise.resolve({ default: () => h('div', null) })
1056
+ },
1057
+ })
1058
+
1059
+ expect(typeof scheduled).toBe('function')
1060
+ cleanup()
1061
+ expect(cancelledId).toBe(99)
1062
+
1063
+ delete (window as unknown as Record<string, unknown>).requestIdleCallback
1064
+ delete (window as unknown as Record<string, unknown>).cancelIdleCallback
1065
+ globalThis.IntersectionObserver = origIO
1066
+ })
1067
+
1068
+ // ─── Interaction strategy tests ─────────────────────────────────────────
1069
+
1070
+ test('interaction: stamps awaiting-interaction marker, hydrates on first click + replays', async () => {
1071
+ document.body.innerHTML =
1072
+ '<pyreon-island data-component="CmdPalette" data-hydrate="interaction" data-props="{}">' +
1073
+ '<button data-testid="trigger" type="button">open</button>' +
1074
+ '</pyreon-island>'
1075
+
1076
+ let loaded = 0
1077
+ let liveClicks = 0
1078
+ const Live: ComponentFn = () => {
1079
+ const onClick = () => {
1080
+ liveClicks++
1081
+ }
1082
+ return h(
1083
+ 'button',
1084
+ { 'data-testid': 'trigger', type: 'button', onClick },
1085
+ 'open',
1086
+ )
1087
+ }
1088
+
1089
+ const cleanup = hydrateIslands({
1090
+ CmdPalette: () => {
1091
+ loaded++
1092
+ return Promise.resolve({ default: Live })
1093
+ },
1094
+ })
1095
+
1096
+ const island = document.querySelector('pyreon-island')!
1097
+ expect(island.getAttribute('data-island-state')).toBe('awaiting-interaction')
1098
+ expect(loaded).toBe(0)
1099
+
1100
+ // First click — stops propagation, triggers hydration, captures replay path.
1101
+ const btn = island.querySelector<HTMLElement>('[data-testid="trigger"]')!
1102
+ btn.click()
1103
+ await new Promise((r) => setTimeout(r, 30))
1104
+ expect(loaded).toBe(1)
1105
+ // After hydration, replay fires the live handler exactly once.
1106
+ expect(liveClicks).toBe(1)
1107
+ expect(island.getAttribute('data-island-state')).toBeNull()
1108
+
1109
+ cleanup()
1110
+ })
1111
+
1112
+ test('interaction: focus event hydrates without click replay', async () => {
1113
+ document.body.innerHTML =
1114
+ '<pyreon-island data-component="MenuFocus" data-hydrate="interaction" data-props="{}">' +
1115
+ '<button type="button">menu</button>' +
1116
+ '</pyreon-island>'
1117
+
1118
+ let loaded = 0
1119
+ const cleanup = hydrateIslands({
1120
+ MenuFocus: () => {
1121
+ loaded++
1122
+ return Promise.resolve({ default: () => h('button', null, 'menu') })
1123
+ },
1124
+ })
1125
+
1126
+ const btn = document.querySelector<HTMLElement>('pyreon-island button')!
1127
+ btn.dispatchEvent(new FocusEvent('focus', { bubbles: true }))
1128
+ await new Promise((r) => setTimeout(r, 20))
1129
+ expect(loaded).toBe(1)
1130
+
1131
+ cleanup()
1132
+ })
1133
+
1134
+ test('interaction(<events>): only listed events trigger hydration', async () => {
1135
+ document.body.innerHTML =
1136
+ '<pyreon-island data-component="OnlyClick" data-hydrate="interaction(click)" data-props="{}">' +
1137
+ '<button type="button">x</button>' +
1138
+ '</pyreon-island>'
1139
+
1140
+ let loaded = 0
1141
+ const cleanup = hydrateIslands({
1142
+ OnlyClick: () => {
1143
+ loaded++
1144
+ return Promise.resolve({ default: () => h('button', null, 'x') })
1145
+ },
1146
+ })
1147
+
1148
+ const btn = document.querySelector<HTMLElement>('pyreon-island button')!
1149
+ // Focus is NOT in the list — must not trigger hydration.
1150
+ btn.dispatchEvent(new FocusEvent('focus', { bubbles: true }))
1151
+ await new Promise((r) => setTimeout(r, 20))
1152
+ expect(loaded).toBe(0)
1153
+ // Click IS in the list — fires it.
1154
+ btn.click()
1155
+ await new Promise((r) => setTimeout(r, 20))
1156
+ expect(loaded).toBe(1)
1157
+
1158
+ cleanup()
1159
+ })
1160
+
1161
+ test('interaction(): empty event list falls back to defaults', async () => {
1162
+ document.body.innerHTML =
1163
+ '<pyreon-island data-component="Defaults" data-hydrate="interaction()" data-props="{}">' +
1164
+ '<button type="button">x</button>' +
1165
+ '</pyreon-island>'
1166
+
1167
+ let loaded = 0
1168
+ const cleanup = hydrateIslands({
1169
+ Defaults: () => {
1170
+ loaded++
1171
+ return Promise.resolve({ default: () => h('button', null, 'x') })
1172
+ },
1173
+ })
1174
+
1175
+ const btn = document.querySelector<HTMLElement>('pyreon-island button')!
1176
+ btn.click()
1177
+ await new Promise((r) => setTimeout(r, 20))
1178
+ expect(loaded).toBe(1)
1179
+
1180
+ cleanup()
1181
+ })
1182
+
1183
+ test('interaction: cleanup() before any interaction removes listeners + clears marker', async () => {
1184
+ document.body.innerHTML =
1185
+ '<pyreon-island data-component="EarlyCancel" data-hydrate="interaction" data-props="{}">' +
1186
+ '<button type="button">x</button>' +
1187
+ '</pyreon-island>'
1188
+
1189
+ let loaded = 0
1190
+ const cleanup = hydrateIslands({
1191
+ EarlyCancel: () => {
1192
+ loaded++
1193
+ return Promise.resolve({ default: () => h('button', null, 'x') })
1194
+ },
1195
+ })
1196
+
1197
+ const island = document.querySelector('pyreon-island')!
1198
+ expect(island.getAttribute('data-island-state')).toBe('awaiting-interaction')
1199
+ cleanup()
1200
+ expect(island.getAttribute('data-island-state')).toBeNull()
1201
+
1202
+ // Click after cleanup — listener has been removed, must not load.
1203
+ const btn = island.querySelector<HTMLElement>('button')!
1204
+ btn.click()
1205
+ await new Promise((r) => setTimeout(r, 20))
1206
+ expect(loaded).toBe(0)
1207
+ })
1208
+
1209
+ test('interaction: click replay falls back to tag+child-index path when no testid', async () => {
1210
+ document.body.innerHTML =
1211
+ '<pyreon-island data-component="NoTestid" data-hydrate="interaction" data-props="{}">' +
1212
+ '<div><button type="button">x</button></div>' +
1213
+ '</pyreon-island>'
1214
+
1215
+ let loaded = 0
1216
+ let liveClicks = 0
1217
+ const Live: ComponentFn = () => {
1218
+ const onClick = () => {
1219
+ liveClicks++
1220
+ }
1221
+ // Same DOM shape so the path resolves: pyreon-island > div > button
1222
+ return h(
1223
+ 'div',
1224
+ null,
1225
+ h('button', { type: 'button', onClick }, 'x'),
1226
+ )
1227
+ }
1228
+
1229
+ const cleanup = hydrateIslands({
1230
+ NoTestid: () => {
1231
+ loaded++
1232
+ return Promise.resolve({ default: Live })
1233
+ },
1234
+ })
1235
+
1236
+ const btn = document.querySelector<HTMLElement>('pyreon-island button')!
1237
+ btn.click()
1238
+ await new Promise((r) => setTimeout(r, 30))
1239
+ expect(loaded).toBe(1)
1240
+ expect(liveClicks).toBe(1)
1241
+
1242
+ cleanup()
1243
+ })
1244
+
1245
+ test('interaction: replay path returns null when live tree shape differs (no replay fired)', async () => {
1246
+ // SSR shape: pyreon-island > div > button
1247
+ document.body.innerHTML =
1248
+ '<pyreon-island data-component="ShapeMismatch" data-hydrate="interaction" data-props="{}">' +
1249
+ '<div><button type="button">x</button></div>' +
1250
+ '</pyreon-island>'
1251
+
1252
+ let liveClicks = 0
1253
+ // Live shape: pyreon-island > section (not div) > button — captured path
1254
+ // expects tag=DIV at step 0, finds SECTION instead. resolveReplayPath
1255
+ // returns null, liveTarget guard skips replay.
1256
+ const Live: ComponentFn = () =>
1257
+ h(
1258
+ 'section',
1259
+ null,
1260
+ h(
1261
+ 'button',
1262
+ {
1263
+ type: 'button',
1264
+ onClick: () => {
1265
+ liveClicks++
1266
+ },
1267
+ },
1268
+ 'x',
1269
+ ),
1270
+ )
1271
+
1272
+ const cleanup = hydrateIslands({
1273
+ ShapeMismatch: () => Promise.resolve({ default: Live }),
1274
+ })
1275
+
1276
+ const btn = document.querySelector<HTMLElement>('pyreon-island button')!
1277
+ btn.click()
1278
+ await new Promise((r) => setTimeout(r, 30))
1279
+ // No replay because the path resolves to null (tag mismatch).
1280
+ expect(liveClicks).toBe(0)
1281
+
1282
+ cleanup()
1283
+ })
1284
+
1285
+ test('interaction: non-click event triggers hydrate without setting replayPath', async () => {
1286
+ // pointerenter is a non-click event; it kicks off hydration but no replay.
1287
+ document.body.innerHTML =
1288
+ '<pyreon-island data-component="HoverOnly" data-hydrate="interaction" data-props="{}">' +
1289
+ '<button type="button">x</button>' +
1290
+ '</pyreon-island>'
1291
+
1292
+ let loaded = 0
1293
+ let liveClicks = 0
1294
+ const Live: ComponentFn = () =>
1295
+ h(
1296
+ 'button',
1297
+ {
1298
+ type: 'button',
1299
+ onClick: () => {
1300
+ liveClicks++
1301
+ },
1302
+ },
1303
+ 'x',
1304
+ )
1305
+ const cleanup = hydrateIslands({
1306
+ HoverOnly: () => {
1307
+ loaded++
1308
+ return Promise.resolve({ default: Live })
1309
+ },
1310
+ })
1311
+
1312
+ const btn = document.querySelector<HTMLElement>('pyreon-island button')!
1313
+ btn.dispatchEvent(new Event('pointerenter', { bubbles: true }))
1314
+ await new Promise((r) => setTimeout(r, 30))
1315
+ expect(loaded).toBe(1)
1316
+ // No click was fired — no replay.
1317
+ expect(liveClicks).toBe(0)
1318
+
1319
+ cleanup()
1320
+ })
1321
+
1322
+ test('marks islands with hydration failure as data-island-error="hydration-failed"', async () => {
1323
+ document.body.innerHTML =
1324
+ '<pyreon-island data-component="Crash" data-props="{}"></pyreon-island>'
1325
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
1326
+
1327
+ const cleanup = hydrateIslands({
1328
+ Crash: () => Promise.reject(new Error('boom')),
1329
+ })
1330
+ await new Promise((r) => setTimeout(r, 50))
1331
+
1332
+ const el = document.querySelector('pyreon-island')!
1333
+ expect(el.getAttribute('data-island-error')).toBe('hydration-failed')
1334
+
1335
+ errorSpy.mockRestore()
1336
+ cleanup()
1337
+ })
1338
+ })
1339
+
1340
+ // ─── perf counter emissions ─────────────────────────────────────────────────
1341
+
1342
+ /**
1343
+ * Runtime gate for the 7 `island.*` counters. The catalog-drift test in
1344
+ * @pyreon/perf-harness only proves the EMIT STRINGS exist in source; this
1345
+ * suite installs a counter sink and asserts each emit actually FIRES at
1346
+ * the right moment under each strategy. Without this, a typo (`'island.hyrated'`
1347
+ * vs `'island.hydrated'`) that's also typo'd in COUNTERS.md would scan-clean
1348
+ * and ship silently dead.
1349
+ *
1350
+ * Each test bisect-verifies its specific counter — remove the matching
1351
+ * `_countSink.__pyreon_count__?.('X')` line in client.ts and the assertion
1352
+ * here flips from `1` to `0` (or the matching count).
1353
+ */
1354
+ describe('island.* counter emissions', () => {
1355
+ let counts: Map<string, number>
1356
+ let savedSink: ((name: string, n?: number) => void) | undefined
1357
+
1358
+ beforeEach(() => {
1359
+ document.body.innerHTML = ''
1360
+ counts = new Map<string, number>()
1361
+ const g = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
1362
+ savedSink = g.__pyreon_count__
1363
+ g.__pyreon_count__ = (name: string, n = 1) => {
1364
+ counts.set(name, (counts.get(name) ?? 0) + n)
1365
+ }
1366
+ })
1367
+
1368
+ afterEach(() => {
1369
+ const g = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
1370
+ if (savedSink) g.__pyreon_count__ = savedSink
1371
+ else delete g.__pyreon_count__
1372
+ })
1373
+
1374
+ test('island.scheduled fires once per scheduled island', async () => {
1375
+ document.body.innerHTML = [
1376
+ '<pyreon-island data-component="A" data-props="{}"></pyreon-island>',
1377
+ '<pyreon-island data-component="B" data-props="{}"></pyreon-island>',
1378
+ ].join('')
1379
+ const Comp: ComponentFn = () => h('div', null, 'x')
1380
+
1381
+ const cleanup = hydrateIslands({
1382
+ A: () => Promise.resolve({ default: Comp }),
1383
+ B: () => Promise.resolve({ default: Comp }),
1384
+ })
1385
+ await new Promise((r) => setTimeout(r, 50))
1386
+
1387
+ expect(counts.get('island.scheduled')).toBe(2)
1388
+ cleanup()
1389
+ })
1390
+
1391
+ test('island.hydrated fires once per successful hydration', async () => {
1392
+ document.body.innerHTML =
1393
+ '<pyreon-island data-component="C" data-props=\'{"v":1}\'></pyreon-island>'
1394
+ const C: ComponentFn = () => h('button', null, 'c')
1395
+
1396
+ const cleanup = hydrateIslands({
1397
+ C: () => Promise.resolve({ default: C }),
1398
+ })
1399
+ await new Promise((r) => setTimeout(r, 50))
1400
+
1401
+ expect(counts.get('island.hydrated')).toBe(1)
1402
+ cleanup()
1403
+ })
1404
+
1405
+ test('island.skipped.never fires once per never-strategy island', async () => {
1406
+ document.body.innerHTML = [
1407
+ '<pyreon-island data-component="N1" data-hydrate="never" data-props="{}"></pyreon-island>',
1408
+ '<pyreon-island data-component="N2" data-hydrate="never" data-props="{}"></pyreon-island>',
1409
+ ].join('')
1410
+
1411
+ const cleanup = hydrateIslands({})
1412
+ await new Promise((r) => setTimeout(r, 30))
1413
+
1414
+ expect(counts.get('island.skipped.never')).toBe(2)
1415
+ expect(counts.get('island.scheduled') ?? 0).toBe(0) // never-islands never proceed to schedule
1416
+ cleanup()
1417
+ })
1418
+
1419
+ test('island.skipped.no-loader fires for unregistered names', async () => {
1420
+ document.body.innerHTML =
1421
+ '<pyreon-island data-component="Missing" data-props="{}"></pyreon-island>'
1422
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
1423
+
1424
+ const cleanup = hydrateIslands({})
1425
+ expect(counts.get('island.skipped.no-loader')).toBe(1)
1426
+ warnSpy.mockRestore()
1427
+ cleanup()
1428
+ })
1429
+
1430
+ test('island.error fires for invalid props JSON', async () => {
1431
+ document.body.innerHTML =
1432
+ '<pyreon-island data-component="Bad" data-props="not valid json"></pyreon-island>'
1433
+ const Bad: ComponentFn = () => h('div', null)
1434
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
1435
+
1436
+ const cleanup = hydrateIslands({
1437
+ Bad: () => Promise.resolve({ default: Bad }),
1438
+ })
1439
+ await new Promise((r) => setTimeout(r, 50))
1440
+
1441
+ expect(counts.get('island.error')).toBe(1)
1442
+ errorSpy.mockRestore()
1443
+ cleanup()
1444
+ })
1445
+
1446
+ test('island.error fires for hydration failure', async () => {
1447
+ document.body.innerHTML =
1448
+ '<pyreon-island data-component="Crash" data-props="{}"></pyreon-island>'
1449
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
1450
+
1451
+ const cleanup = hydrateIslands({
1452
+ Crash: () => Promise.reject(new Error('boom')),
1453
+ })
1454
+ await new Promise((r) => setTimeout(r, 50))
1455
+
1456
+ expect(counts.get('island.error')).toBe(1)
1457
+ errorSpy.mockRestore()
1458
+ cleanup()
1459
+ })
1460
+
1461
+ test('island.prefetch fires per pre-warm loader call (idle strategy)', async () => {
1462
+ document.body.innerHTML =
1463
+ '<pyreon-island data-component="P" data-hydrate="visible" data-prefetch="idle" data-props="{}"></pyreon-island>'
1464
+ const P: ComponentFn = () => h('div', null, 'p')
1465
+
1466
+ const cleanup = hydrateIslands({
1467
+ P: () => Promise.resolve({ default: P }),
1468
+ })
1469
+ // Wait for requestIdleCallback fallback timer
1470
+ await new Promise((r) => setTimeout(r, 250))
1471
+
1472
+ expect(counts.get('island.prefetch') ?? 0).toBeGreaterThanOrEqual(1)
1473
+ cleanup()
1474
+ })
1475
+
1476
+ test('counters do NOT fire when sink is undefined (graceful no-op)', async () => {
1477
+ const g = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
1478
+ delete g.__pyreon_count__ // remove the sink installed in beforeEach
1479
+
1480
+ document.body.innerHTML =
1481
+ '<pyreon-island data-component="Q" data-props="{}"></pyreon-island>'
1482
+ const Q: ComponentFn = () => h('div', null, 'q')
1483
+
1484
+ // Should not throw — the optional-chain short-circuits.
1485
+ const cleanup = hydrateIslands({
1486
+ Q: () => Promise.resolve({ default: Q }),
1487
+ })
1488
+ await new Promise((r) => setTimeout(r, 50))
1489
+ cleanup()
1490
+ })
577
1491
  })