@pyreon/server 0.1.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.
@@ -0,0 +1,577 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+
5
+ import type { ComponentFn } from "@pyreon/core"
6
+ import { h } from "@pyreon/core"
7
+ import { hydrateIslands, startClient } from "../client"
8
+
9
+ // ─── startClient ────────────────────────────────────────────────────────────
10
+
11
+ describe("startClient", () => {
12
+ beforeEach(() => {
13
+ document.body.innerHTML = ""
14
+ delete (window as unknown as Record<string, unknown>).__PYREON_LOADER_DATA__
15
+ })
16
+
17
+ test("throws when container is not found", () => {
18
+ const App: ComponentFn = () => h("div", null, "app")
19
+ expect(() => startClient({ App, routes: [] })).toThrow('Container "#app" not found')
20
+ })
21
+
22
+ test("throws with custom container selector not found", () => {
23
+ const App: ComponentFn = () => h("div", null, "app")
24
+ expect(() => startClient({ App, routes: [], container: "#missing" })).toThrow(
25
+ 'Container "#missing" not found',
26
+ )
27
+ })
28
+
29
+ test("mounts app into empty container", () => {
30
+ document.body.innerHTML = '<div id="app"></div>'
31
+ const App: ComponentFn = () => h("div", null, "Hello")
32
+ const cleanup = startClient({ App, routes: [{ path: "/", component: App }] })
33
+ expect(typeof cleanup).toBe("function")
34
+ cleanup()
35
+ })
36
+
37
+ test("hydrates app when container has SSR content", () => {
38
+ document.body.innerHTML = '<div id="app"><div>SSR Content</div></div>'
39
+ const App: ComponentFn = () => h("div", null, "SSR Content")
40
+ const cleanup = startClient({ App, routes: [{ path: "/", component: App }] })
41
+ expect(typeof cleanup).toBe("function")
42
+ cleanup()
43
+ })
44
+
45
+ test("hydrates loader data from window global", () => {
46
+ document.body.innerHTML = '<div id="app"></div>'
47
+ ;(window as unknown as Record<string, unknown>).__PYREON_LOADER_DATA__ = {
48
+ "/": { items: [1, 2] },
49
+ }
50
+ const App: ComponentFn = () => h("div", null, "app")
51
+ const cleanup = startClient({ App, routes: [{ path: "/", component: App }] })
52
+ expect(typeof cleanup).toBe("function")
53
+ cleanup()
54
+ })
55
+
56
+ test("ignores non-object loader data", () => {
57
+ document.body.innerHTML = '<div id="app"></div>'
58
+ ;(window as unknown as Record<string, unknown>).__PYREON_LOADER_DATA__ = "invalid"
59
+ const App: ComponentFn = () => h("div", null, "app")
60
+ const cleanup = startClient({ App, routes: [{ path: "/", component: App }] })
61
+ expect(typeof cleanup).toBe("function")
62
+ cleanup()
63
+ })
64
+
65
+ test("accepts Element directly as container", () => {
66
+ document.body.innerHTML = '<div id="custom"></div>'
67
+ const el = document.getElementById("custom")!
68
+ const App: ComponentFn = () => h("div", null, "app")
69
+ const cleanup = startClient({ App, routes: [{ path: "/", component: App }], container: el })
70
+ expect(typeof cleanup).toBe("function")
71
+ cleanup()
72
+ })
73
+ })
74
+
75
+ // ─── hydrateIslands ─────────────────────────────────────────────────────────
76
+
77
+ describe("hydrateIslands", () => {
78
+ beforeEach(() => {
79
+ document.body.innerHTML = ""
80
+ })
81
+
82
+ test("returns cleanup function with no islands on page", () => {
83
+ const cleanup = hydrateIslands({})
84
+ expect(typeof cleanup).toBe("function")
85
+ cleanup()
86
+ })
87
+
88
+ test("skips islands without data-component attribute", () => {
89
+ document.body.innerHTML = "<pyreon-island></pyreon-island>"
90
+ const cleanup = hydrateIslands({})
91
+ expect(typeof cleanup).toBe("function")
92
+ cleanup()
93
+ })
94
+
95
+ test("warns and skips islands with no matching loader", () => {
96
+ document.body.innerHTML =
97
+ '<pyreon-island data-component="Missing" data-props="{}"></pyreon-island>'
98
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
99
+ const cleanup = hydrateIslands({})
100
+ expect(warnSpy).toHaveBeenCalledWith(
101
+ expect.stringContaining('No loader registered for island "Missing"'),
102
+ )
103
+ warnSpy.mockRestore()
104
+ cleanup()
105
+ })
106
+
107
+ test("hydrates island with 'load' strategy (default)", async () => {
108
+ document.body.innerHTML =
109
+ '<pyreon-island data-component="Counter" data-props=\'{"count":5}\'></pyreon-island>'
110
+
111
+ const Counter: ComponentFn = (props) =>
112
+ h("button", null, `Count: ${(props as Record<string, unknown>).count}`)
113
+
114
+ const cleanup = hydrateIslands({
115
+ Counter: () => Promise.resolve({ default: Counter }),
116
+ })
117
+ // Give async hydration time to resolve
118
+ await new Promise((r) => setTimeout(r, 50))
119
+ expect(typeof cleanup).toBe("function")
120
+ cleanup()
121
+ })
122
+
123
+ test("hydrates island with direct function module", async () => {
124
+ document.body.innerHTML =
125
+ '<pyreon-island data-component="Widget" data-props="{}"></pyreon-island>'
126
+
127
+ const Widget: ComponentFn = () => h("div", null, "widget")
128
+
129
+ const cleanup = hydrateIslands({
130
+ Widget: () => Promise.resolve({ default: Widget }),
131
+ })
132
+ await new Promise((r) => setTimeout(r, 50))
133
+ cleanup()
134
+ })
135
+
136
+ test("handles 'idle' hydration strategy", async () => {
137
+ document.body.innerHTML =
138
+ '<pyreon-island data-component="Lazy" data-hydrate="idle" data-props="{}"></pyreon-island>'
139
+
140
+ const Lazy: ComponentFn = () => h("div", null, "lazy")
141
+
142
+ const cleanup = hydrateIslands({
143
+ Lazy: () => Promise.resolve({ default: Lazy }),
144
+ })
145
+ // Wait for idle callback or timeout fallback
146
+ await new Promise((r) => setTimeout(r, 300))
147
+ expect(typeof cleanup).toBe("function")
148
+ cleanup()
149
+ })
150
+
151
+ test("handles 'never' hydration strategy — does not hydrate", async () => {
152
+ document.body.innerHTML =
153
+ '<pyreon-island data-component="Static" data-hydrate="never" data-props="{}"></pyreon-island>'
154
+
155
+ let called = false
156
+ const Static: ComponentFn = () => {
157
+ called = true
158
+ return h("div", null, "static")
159
+ }
160
+
161
+ const cleanup = hydrateIslands({
162
+ Static: () => Promise.resolve({ default: Static }),
163
+ })
164
+ await new Promise((r) => setTimeout(r, 100))
165
+ expect(called).toBe(false)
166
+ cleanup()
167
+ })
168
+
169
+ test("handles 'visible' hydration strategy", async () => {
170
+ document.body.innerHTML =
171
+ '<pyreon-island data-component="Visible" data-hydrate="visible" data-props="{}"></pyreon-island>'
172
+
173
+ const Visible: ComponentFn = () => h("div", null, "visible")
174
+
175
+ const cleanup = hydrateIslands({
176
+ Visible: () => Promise.resolve({ default: Visible }),
177
+ })
178
+ expect(typeof cleanup).toBe("function")
179
+ cleanup()
180
+ })
181
+
182
+ test("handles 'media(...)' hydration strategy — immediate match", async () => {
183
+ document.body.innerHTML =
184
+ '<pyreon-island data-component="Responsive" data-hydrate="media(all)" data-props="{}"></pyreon-island>'
185
+
186
+ const Responsive: ComponentFn = () => h("div", null, "responsive")
187
+
188
+ const cleanup = hydrateIslands({
189
+ Responsive: () => Promise.resolve({ default: Responsive }),
190
+ })
191
+ await new Promise((r) => setTimeout(r, 50))
192
+ cleanup()
193
+ })
194
+
195
+ test("handles 'media(...)' hydration strategy — deferred match", async () => {
196
+ // Use double parens so slice(6, -1) produces "(min-width: 99999px)" —
197
+ // a valid media query that happy-dom correctly evaluates as non-matching.
198
+ document.body.innerHTML =
199
+ '<pyreon-island data-component="Responsive" data-hydrate="media((min-width: 99999px))" data-props="{}"></pyreon-island>'
200
+
201
+ let hydrated = false
202
+ const Responsive: ComponentFn = () => {
203
+ hydrated = true
204
+ return h("div", null, "responsive")
205
+ }
206
+
207
+ const cleanup = hydrateIslands({
208
+ Responsive: () => Promise.resolve({ default: Responsive }),
209
+ })
210
+ await new Promise((r) => setTimeout(r, 50))
211
+ // Media query doesn't match, so should not hydrate yet
212
+ expect(hydrated).toBe(false)
213
+ cleanup()
214
+ })
215
+
216
+ test("handles invalid island props JSON gracefully", async () => {
217
+ document.body.innerHTML =
218
+ '<pyreon-island data-component="Bad" data-props="not valid json"></pyreon-island>'
219
+
220
+ const Bad: ComponentFn = () => h("div", null, "bad")
221
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
222
+
223
+ const cleanup = hydrateIslands({
224
+ Bad: () => Promise.resolve({ default: Bad }),
225
+ })
226
+ await new Promise((r) => setTimeout(r, 50))
227
+ expect(errorSpy).toHaveBeenCalledWith(
228
+ expect.stringContaining("Invalid island props JSON"),
229
+ expect.anything(),
230
+ )
231
+ errorSpy.mockRestore()
232
+ cleanup()
233
+ })
234
+
235
+ test("handles island props that parse to non-object", async () => {
236
+ document.body.innerHTML =
237
+ '<pyreon-island data-component="Arr" data-props="[1,2,3]"></pyreon-island>'
238
+
239
+ const Arr: ComponentFn = () => h("div", null)
240
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
241
+
242
+ const cleanup = hydrateIslands({
243
+ Arr: () => Promise.resolve({ default: Arr }),
244
+ })
245
+ await new Promise((r) => setTimeout(r, 50))
246
+ expect(errorSpy).toHaveBeenCalledWith(
247
+ expect.stringContaining("Invalid island props JSON"),
248
+ expect.anything(),
249
+ )
250
+ errorSpy.mockRestore()
251
+ cleanup()
252
+ })
253
+
254
+ test("handles island props that parse to null", async () => {
255
+ document.body.innerHTML =
256
+ '<pyreon-island data-component="Null" data-props="null"></pyreon-island>'
257
+
258
+ const Null: ComponentFn = () => h("div", null)
259
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
260
+
261
+ const cleanup = hydrateIslands({
262
+ Null: () => Promise.resolve({ default: Null }),
263
+ })
264
+ await new Promise((r) => setTimeout(r, 50))
265
+ expect(errorSpy).toHaveBeenCalledWith(
266
+ expect.stringContaining("Invalid island props JSON"),
267
+ expect.anything(),
268
+ )
269
+ errorSpy.mockRestore()
270
+ cleanup()
271
+ })
272
+
273
+ test("handles loader failure gracefully", async () => {
274
+ document.body.innerHTML =
275
+ '<pyreon-island data-component="Fail" data-props="{}"></pyreon-island>'
276
+
277
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
278
+
279
+ const cleanup = hydrateIslands({
280
+ Fail: () => Promise.reject(new Error("import failed")),
281
+ })
282
+ await new Promise((r) => setTimeout(r, 50))
283
+ expect(errorSpy).toHaveBeenCalledWith(
284
+ expect.stringContaining('Failed to hydrate island "Fail"'),
285
+ expect.anything(),
286
+ )
287
+ errorSpy.mockRestore()
288
+ cleanup()
289
+ })
290
+
291
+ test("uses default empty props when data-props is missing", async () => {
292
+ document.body.innerHTML = '<pyreon-island data-component="NoProps"></pyreon-island>'
293
+
294
+ const NoProps: ComponentFn = () => h("div", null, "no-props")
295
+
296
+ const cleanup = hydrateIslands({
297
+ NoProps: () => Promise.resolve({ default: NoProps }),
298
+ })
299
+ await new Promise((r) => setTimeout(r, 50))
300
+ cleanup()
301
+ })
302
+
303
+ test("defaults hydration strategy to 'load' when data-hydrate is missing", async () => {
304
+ document.body.innerHTML =
305
+ '<pyreon-island data-component="Default" data-props="{}"></pyreon-island>'
306
+
307
+ const Default: ComponentFn = () => h("div", null, "default")
308
+
309
+ const cleanup = hydrateIslands({
310
+ Default: () => Promise.resolve({ default: Default }),
311
+ })
312
+ await new Promise((r) => setTimeout(r, 50))
313
+ cleanup()
314
+ })
315
+
316
+ test("cleanup cancels idle callbacks", async () => {
317
+ document.body.innerHTML =
318
+ '<pyreon-island data-component="IdleCancel" data-hydrate="idle" data-props="{}"></pyreon-island>'
319
+
320
+ let hydrated = false
321
+ const IdleCancel: ComponentFn = () => {
322
+ hydrated = true
323
+ return h("div", null)
324
+ }
325
+
326
+ const cleanup = hydrateIslands({
327
+ IdleCancel: () => Promise.resolve({ default: IdleCancel }),
328
+ })
329
+ // Cancel immediately before idle fires
330
+ cleanup()
331
+ await new Promise((r) => setTimeout(r, 300))
332
+ expect(hydrated).toBe(false)
333
+ })
334
+
335
+ test("handles unknown strategy as fallback (immediate hydration)", async () => {
336
+ document.body.innerHTML =
337
+ '<pyreon-island data-component="Unknown" data-hydrate="custom-unknown" data-props="{}"></pyreon-island>'
338
+
339
+ const Unknown: ComponentFn = () => h("div", null, "unknown")
340
+
341
+ const cleanup = hydrateIslands({
342
+ Unknown: () => Promise.resolve({ default: Unknown }),
343
+ })
344
+ await new Promise((r) => setTimeout(r, 50))
345
+ cleanup()
346
+ })
347
+
348
+ test("multiple islands hydrate independently", async () => {
349
+ document.body.innerHTML = `
350
+ <pyreon-island data-component="A" data-props='{"id":1}'></pyreon-island>
351
+ <pyreon-island data-component="B" data-props='{"id":2}'></pyreon-island>
352
+ `
353
+
354
+ const A: ComponentFn = () => h("div", null, "a")
355
+ const B: ComponentFn = () => h("div", null, "b")
356
+
357
+ const cleanup = hydrateIslands({
358
+ A: () => Promise.resolve({ default: A }),
359
+ B: () => Promise.resolve({ default: B }),
360
+ })
361
+ await new Promise((r) => setTimeout(r, 50))
362
+ cleanup()
363
+ })
364
+
365
+ test("observeVisibility falls back to immediate callback when IntersectionObserver is missing", async () => {
366
+ document.body.innerHTML =
367
+ '<pyreon-island data-component="Fallback" data-hydrate="visible" data-props="{}"></pyreon-island>'
368
+
369
+ let hydrated = false
370
+ const Fallback: ComponentFn = () => {
371
+ hydrated = true
372
+ return h("div", null, "fallback")
373
+ }
374
+
375
+ // Remove IntersectionObserver to trigger fallback
376
+ const origIO = (window as unknown as Record<string, unknown>).IntersectionObserver
377
+ delete (window as unknown as Record<string, unknown>).IntersectionObserver
378
+
379
+ const cleanup = hydrateIslands({
380
+ Fallback: () => Promise.resolve({ default: Fallback }),
381
+ })
382
+ await new Promise((r) => setTimeout(r, 100))
383
+ expect(hydrated).toBe(true)
384
+ cleanup()
385
+
386
+ ;(window as unknown as Record<string, unknown>).IntersectionObserver = origIO
387
+ })
388
+
389
+ test("visible strategy: IntersectionObserver fires when element becomes visible", async () => {
390
+ document.body.innerHTML =
391
+ '<pyreon-island data-component="VisObs" data-hydrate="visible" data-props="{}"></pyreon-island>'
392
+
393
+ let hydrated = false
394
+ const VisObs: ComponentFn = () => {
395
+ hydrated = true
396
+ return h("div", null, "vis")
397
+ }
398
+
399
+ // Mock IntersectionObserver to call callback with isIntersecting: true
400
+ const origIO = globalThis.IntersectionObserver
401
+ let observerCb: IntersectionObserverCallback | null = null
402
+ globalThis.IntersectionObserver = class {
403
+ constructor(cb: IntersectionObserverCallback) {
404
+ observerCb = cb
405
+ }
406
+ observe(_el: Element) {
407
+ // Trigger intersection immediately
408
+ if (observerCb) {
409
+ observerCb(
410
+ [{ isIntersecting: true } as IntersectionObserverEntry],
411
+ this as unknown as IntersectionObserver,
412
+ )
413
+ }
414
+ }
415
+ disconnect() {}
416
+ unobserve() {}
417
+ takeRecords() {
418
+ return []
419
+ }
420
+ get root() {
421
+ return null
422
+ }
423
+ get rootMargin() {
424
+ return ""
425
+ }
426
+ get thresholds() {
427
+ return []
428
+ }
429
+ } as unknown as typeof IntersectionObserver
430
+
431
+ const cleanup = hydrateIslands({
432
+ VisObs: () => Promise.resolve({ default: VisObs }),
433
+ })
434
+ await new Promise((r) => setTimeout(r, 100))
435
+ expect(hydrated).toBe(true)
436
+ cleanup()
437
+
438
+ globalThis.IntersectionObserver = origIO
439
+ })
440
+
441
+ test("visible strategy: IntersectionObserver entry not intersecting does not hydrate", async () => {
442
+ document.body.innerHTML =
443
+ '<pyreon-island data-component="VisNoInt" data-hydrate="visible" data-props="{}"></pyreon-island>'
444
+
445
+ let hydrated = false
446
+ const VisNoInt: ComponentFn = () => {
447
+ hydrated = true
448
+ return h("div", null, "vis")
449
+ }
450
+
451
+ const origIO = globalThis.IntersectionObserver
452
+ globalThis.IntersectionObserver = class {
453
+ constructor(private cb: IntersectionObserverCallback) {}
454
+ observe(_el: Element) {
455
+ // Trigger with isIntersecting: false
456
+ this.cb(
457
+ [{ isIntersecting: false } as IntersectionObserverEntry],
458
+ this as unknown as IntersectionObserver,
459
+ )
460
+ }
461
+ disconnect() {}
462
+ unobserve() {}
463
+ takeRecords() {
464
+ return []
465
+ }
466
+ get root() {
467
+ return null
468
+ }
469
+ get rootMargin() {
470
+ return ""
471
+ }
472
+ get thresholds() {
473
+ return []
474
+ }
475
+ } as unknown as typeof IntersectionObserver
476
+
477
+ const cleanup = hydrateIslands({
478
+ VisNoInt: () => Promise.resolve({ default: VisNoInt }),
479
+ })
480
+ await new Promise((r) => setTimeout(r, 100))
481
+ expect(hydrated).toBe(false)
482
+ cleanup()
483
+
484
+ globalThis.IntersectionObserver = origIO
485
+ })
486
+
487
+ test("media strategy: deferred match fires onChange handler", async () => {
488
+ document.body.innerHTML =
489
+ '<pyreon-island data-component="MediaDeferred" data-hydrate="media((min-width: 99999px))" data-props="{}"></pyreon-island>'
490
+
491
+ let hydrated = false
492
+ const MediaDeferred: ComponentFn = () => {
493
+ hydrated = true
494
+ return h("div", null, "media")
495
+ }
496
+
497
+ // Mock matchMedia to initially not match, then trigger change
498
+ const origMatchMedia = window.matchMedia
499
+ let storedOnChange: ((e: MediaQueryListEvent) => void) | null = null
500
+ window.matchMedia = (_query: string) => {
501
+ const mql = {
502
+ matches: false,
503
+ media: _query,
504
+ onchange: null,
505
+ addEventListener: (_type: string, listener: (e: MediaQueryListEvent) => void) => {
506
+ storedOnChange = listener
507
+ },
508
+ removeEventListener: () => {},
509
+ addListener: () => {},
510
+ removeListener: () => {},
511
+ dispatchEvent: () => true,
512
+ } as unknown as MediaQueryList
513
+ return mql
514
+ }
515
+
516
+ const cleanup = hydrateIslands({
517
+ MediaDeferred: () => Promise.resolve({ default: MediaDeferred }),
518
+ })
519
+ await new Promise((r) => setTimeout(r, 50))
520
+ expect(hydrated).toBe(false)
521
+
522
+ // Now simulate the media query matching
523
+ const onChange1 = storedOnChange as ((e: MediaQueryListEvent) => void) | null
524
+ if (onChange1) {
525
+ onChange1({ matches: true } as MediaQueryListEvent)
526
+ }
527
+ await new Promise((r) => setTimeout(r, 100))
528
+ expect(hydrated).toBe(true)
529
+
530
+ cleanup()
531
+ window.matchMedia = origMatchMedia
532
+ })
533
+
534
+ test("media strategy: onChange handler ignores non-matching events", async () => {
535
+ document.body.innerHTML =
536
+ '<pyreon-island data-component="MediaNoMatch" data-hydrate="media((min-width: 99999px))" data-props="{}"></pyreon-island>'
537
+
538
+ let hydrated = false
539
+ const MediaNoMatch: ComponentFn = () => {
540
+ hydrated = true
541
+ return h("div", null, "media")
542
+ }
543
+
544
+ const origMatchMedia = window.matchMedia
545
+ let storedOnChange: ((e: MediaQueryListEvent) => void) | null = null
546
+ window.matchMedia = (_query: string) => {
547
+ return {
548
+ matches: false,
549
+ media: _query,
550
+ onchange: null,
551
+ addEventListener: (_type: string, listener: (e: MediaQueryListEvent) => void) => {
552
+ storedOnChange = listener
553
+ },
554
+ removeEventListener: () => {},
555
+ addListener: () => {},
556
+ removeListener: () => {},
557
+ dispatchEvent: () => true,
558
+ } as unknown as MediaQueryList
559
+ }
560
+
561
+ const cleanup = hydrateIslands({
562
+ MediaNoMatch: () => Promise.resolve({ default: MediaNoMatch }),
563
+ })
564
+ await new Promise((r) => setTimeout(r, 50))
565
+
566
+ // Trigger with matches: false — should not hydrate
567
+ const onChange2 = storedOnChange as ((e: MediaQueryListEvent) => void) | null
568
+ if (onChange2) {
569
+ onChange2({ matches: false } as MediaQueryListEvent)
570
+ }
571
+ await new Promise((r) => setTimeout(r, 100))
572
+ expect(hydrated).toBe(false)
573
+
574
+ cleanup()
575
+ window.matchMedia = origMatchMedia
576
+ })
577
+ })