@pyreon/server 0.14.0 → 0.16.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.
- package/lib/analysis/client.js.html +1 -1
- package/lib/analysis/index.js.html +1 -1
- package/lib/client.js +206 -10
- package/lib/index.js +53 -115
- package/lib/types/client.d.ts +44 -5
- package/lib/types/index.d.ts +9 -9
- package/package.json +12 -8
- package/src/client.ts +340 -11
- package/src/handler.ts +34 -4
- package/src/html.ts +7 -3
- package/src/island.ts +109 -24
- package/src/manifest.ts +65 -9
- package/src/tests/client.test.ts +915 -1
- package/src/tests/islands.browser.test.tsx +512 -0
- package/src/tests/manifest-snapshot.test.ts +2 -0
- package/src/tests/server.test.ts +296 -1
- package/lib/client.js.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/types/client.d.ts.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
package/src/tests/client.test.ts
CHANGED
|
@@ -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
|
})
|