@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.
- package/LICENSE +21 -0
- package/README.md +91 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +294 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +311 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +190 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +53 -0
- package/src/client.ts +239 -0
- package/src/handler.ts +187 -0
- package/src/html.ts +58 -0
- package/src/index.ts +69 -0
- package/src/island.ts +137 -0
- package/src/middleware.ts +39 -0
- package/src/ssg.ts +143 -0
- package/src/tests/client.test.ts +577 -0
- package/src/tests/server.test.ts +635 -0
|
@@ -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
|
+
})
|