@pyreon/server 0.15.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.
@@ -0,0 +1,512 @@
1
+ /**
2
+ * Real-Chromium smoke tests for `hydrateIslands()`.
3
+ *
4
+ * What this catches that the existing happy-dom unit tests CAN'T:
5
+ *
6
+ * - `IntersectionObserver` timing (real Chromium fires it; happy-dom
7
+ * doesn't ship a working impl).
8
+ * - `requestIdleCallback` availability + timing.
9
+ * - `matchMedia` real query matching against the actual viewport.
10
+ * - Real custom-element behavior on `<pyreon-island>`.
11
+ * - Real hydration replacing the SSR'd children with the live VNode tree.
12
+ * - The `data-island-error` markers landing on the right element under
13
+ * real rendering / real timing.
14
+ */
15
+
16
+ import type { ComponentFn } from '@pyreon/core'
17
+ import { describe, expect, it } from 'vitest'
18
+ import { signal } from '@pyreon/reactivity'
19
+ import { hydrateIslands } from '../client'
20
+
21
+ const flushFrames = () =>
22
+ new Promise<void>((resolve) => {
23
+ queueMicrotask(() => requestAnimationFrame(() => resolve()))
24
+ })
25
+
26
+ const settle = async (loops = 5) => {
27
+ for (let i = 0; i < loops; i++) await flushFrames()
28
+ }
29
+
30
+ interface IslandHostOptions {
31
+ hydrate: string
32
+ props?: Record<string, unknown>
33
+ componentName?: string
34
+ initialHtml?: string
35
+ }
36
+
37
+ const installIsland = ({
38
+ hydrate,
39
+ props = {},
40
+ componentName = 'Counter',
41
+ initialHtml = '<span data-testid="ssr-children">SSR children</span>',
42
+ }: IslandHostOptions): HTMLElement => {
43
+ const island = document.createElement('pyreon-island') as HTMLElement
44
+ island.setAttribute('data-component', componentName)
45
+ island.setAttribute('data-props', JSON.stringify(props))
46
+ island.setAttribute('data-hydrate', hydrate)
47
+ island.innerHTML = initialHtml
48
+ document.body.appendChild(island)
49
+ return island
50
+ }
51
+
52
+ const cleanupIslands = (): void => {
53
+ for (const el of document.querySelectorAll('pyreon-island')) el.remove()
54
+ }
55
+
56
+ describe('@pyreon/server — hydrateIslands in real Chromium', () => {
57
+ it('hydrate=load: hydrates the component immediately and binds event handlers', async () => {
58
+ // The SSR children inside <pyreon-island> are what the server-rendered
59
+ // component output looks like; on the client we hydrate by binding the
60
+ // live VNode tree to the existing DOM. Match the SSR shape to the VNode
61
+ // shape so hydrateRoot can attach the click handler.
62
+ const island = installIsland({
63
+ hydrate: 'load',
64
+ props: { initial: 7 },
65
+ initialHtml: '<button data-testid="real-counter" type="button">7</button>',
66
+ })
67
+
68
+ // Note: this test file is compiled by vite's oxc JSX transform (not
69
+ // Pyreon's compiler), so signal auto-call does NOT apply. Reactive reads
70
+ // must be wrapped in an explicit accessor `{() => signal()}`.
71
+ const Counter: ComponentFn = (props) => {
72
+ const count = signal((props.initial as number) ?? 0)
73
+ return (
74
+ <button
75
+ data-testid="real-counter"
76
+ type="button"
77
+ onClick={() => count.set(count() + 1)}
78
+ >
79
+ {() => String(count())}
80
+ </button>
81
+ )
82
+ }
83
+
84
+ const cleanup = hydrateIslands({
85
+ Counter: () => Promise.resolve({ default: Counter }),
86
+ })
87
+
88
+ await settle()
89
+ const btn = island.querySelector<HTMLButtonElement>('[data-testid="real-counter"]')
90
+ expect(btn).not.toBeNull()
91
+ expect(btn?.textContent).toBe('7')
92
+
93
+ // hydrateRoot may replace SSR DOM with the live VNode tree — re-query
94
+ // to find the (potentially) new node that has the click handler bound.
95
+ btn?.click()
96
+ await settle()
97
+ const liveBtn = island.querySelector<HTMLButtonElement>('[data-testid="real-counter"]')
98
+ // If the original captured node is still attached, click it; otherwise the
99
+ // live one already received the dispatch.
100
+ if (liveBtn && liveBtn !== btn) liveBtn.click()
101
+ await settle()
102
+ expect(liveBtn?.textContent).toBe('8')
103
+
104
+ cleanup()
105
+ cleanupIslands()
106
+ })
107
+
108
+ it('hydrate=visible: stays SSR-only until IntersectionObserver fires, then hydrates', async () => {
109
+ // Push the island below the fold so it isn't initially in view.
110
+ const spacer = document.createElement('div')
111
+ spacer.style.height = '5000px'
112
+ document.body.appendChild(spacer)
113
+
114
+ const island = installIsland({
115
+ hydrate: 'visible',
116
+ componentName: 'Comments',
117
+ })
118
+
119
+ let loaderCalled = false
120
+ const Comments = () => <div data-testid="real-comments">comments loaded</div>
121
+
122
+ const cleanup = hydrateIslands({
123
+ Comments: () => {
124
+ loaderCalled = true
125
+ return Promise.resolve({ default: Comments })
126
+ },
127
+ })
128
+
129
+ // Without scrolling, the loader must NOT have been called yet.
130
+ await settle()
131
+ expect(loaderCalled).toBe(false)
132
+ expect(island.querySelector('[data-testid="ssr-children"]')).not.toBeNull()
133
+
134
+ // Scroll into view → IntersectionObserver fires → loader runs.
135
+ island.scrollIntoView({ behavior: 'instant', block: 'center' })
136
+ // IntersectionObserver dispatches asynchronously — needs a few frames
137
+ // before the callback fires AND the dynamic import resolves.
138
+ for (let i = 0; i < 30; i++) {
139
+ await flushFrames()
140
+ if (loaderCalled && island.querySelector('[data-testid="real-comments"]')) break
141
+ }
142
+ expect(loaderCalled).toBe(true)
143
+ expect(island.querySelector('[data-testid="real-comments"]')).not.toBeNull()
144
+
145
+ cleanup()
146
+ cleanupIslands()
147
+ spacer.remove()
148
+ })
149
+
150
+ it('hydrate=media(...): only fires when matchMedia(query).matches is true', async () => {
151
+ // Use a query guaranteed to be true in headless Chromium.
152
+ const island = installIsland({
153
+ hydrate: 'media((min-width: 1px))',
154
+ componentName: 'Menu',
155
+ })
156
+
157
+ let loaderCalled = false
158
+ const cleanup = hydrateIslands({
159
+ Menu: () => {
160
+ loaderCalled = true
161
+ return Promise.resolve({ default: () => <div data-testid="menu-real">menu</div> })
162
+ },
163
+ })
164
+
165
+ await settle()
166
+ expect(loaderCalled).toBe(true)
167
+ expect(island.querySelector('[data-testid="menu-real"]')).not.toBeNull()
168
+
169
+ cleanup()
170
+ cleanupIslands()
171
+ })
172
+
173
+ it('hydrate=never: never loads and leaves SSR children intact', async () => {
174
+ const island = installIsland({
175
+ hydrate: 'never',
176
+ componentName: 'Static',
177
+ })
178
+
179
+ let loaderCalled = false
180
+ const cleanup = hydrateIslands({
181
+ Static: () => {
182
+ loaderCalled = true
183
+ return Promise.resolve({ default: () => <div>nope</div> })
184
+ },
185
+ })
186
+
187
+ await settle()
188
+ expect(loaderCalled).toBe(false)
189
+ expect(island.querySelector('[data-testid="ssr-children"]')?.textContent).toBe(
190
+ 'SSR children',
191
+ )
192
+
193
+ cleanup()
194
+ cleanupIslands()
195
+ })
196
+
197
+ it('hydrate=interaction: stays SSR-only until first focus/click/pointerenter; one-shot', async () => {
198
+ const island = installIsland({
199
+ hydrate: 'interaction',
200
+ componentName: 'CmdK',
201
+ })
202
+
203
+ let loaderCalls = 0
204
+ const cleanup = hydrateIslands({
205
+ CmdK: () => {
206
+ loaderCalls += 1
207
+ return Promise.resolve({
208
+ default: () => <div data-testid="cmdk-mounted">mounted</div>,
209
+ })
210
+ },
211
+ })
212
+
213
+ await settle()
214
+ expect(loaderCalls).toBe(0)
215
+ // Listeners attached — surfaced via data-island-state for assertions.
216
+ expect(island.getAttribute('data-island-state')).toBe('awaiting-interaction')
217
+ expect(island.querySelector('[data-testid="cmdk-mounted"]')).toBeNull()
218
+
219
+ // First click triggers hydrate.
220
+ island.click()
221
+ for (let i = 0; i < 20; i++) {
222
+ await flushFrames()
223
+ if (island.querySelector('[data-testid="cmdk-mounted"]')) break
224
+ }
225
+ expect(loaderCalls).toBe(1)
226
+ expect(island.querySelector('[data-testid="cmdk-mounted"]')).not.toBeNull()
227
+ expect(island.getAttribute('data-island-state')).toBeNull()
228
+
229
+ // Subsequent clicks must NOT re-fire the loader (one-shot listeners).
230
+ island.click()
231
+ island.click()
232
+ island.click()
233
+ await settle()
234
+ expect(loaderCalls).toBe(1)
235
+
236
+ cleanup()
237
+ cleanupIslands()
238
+ })
239
+
240
+ it('hydrate=interaction(focus): only the named events trigger hydrate', async () => {
241
+ const island = installIsland({
242
+ hydrate: 'interaction(focus)',
243
+ componentName: 'FocusOnly',
244
+ })
245
+ // Make the island actually focusable so dispatched focus event has a target.
246
+ island.tabIndex = 0
247
+
248
+ let loaderCalls = 0
249
+ const cleanup = hydrateIslands({
250
+ FocusOnly: () => {
251
+ loaderCalls += 1
252
+ return Promise.resolve({ default: () => <div>focused</div> })
253
+ },
254
+ })
255
+
256
+ await settle()
257
+ // A click should NOT trigger this strategy — only focus.
258
+ island.click()
259
+ await settle()
260
+ expect(loaderCalls).toBe(0)
261
+
262
+ // Focus DOES trigger.
263
+ island.dispatchEvent(new FocusEvent('focus'))
264
+ for (let i = 0; i < 20; i++) {
265
+ await flushFrames()
266
+ if (loaderCalls > 0) break
267
+ }
268
+ expect(loaderCalls).toBe(1)
269
+
270
+ cleanup()
271
+ cleanupIslands()
272
+ })
273
+
274
+ it('hydrate=interaction: cleanup() removes listeners before any interaction', async () => {
275
+ const island = installIsland({
276
+ hydrate: 'interaction',
277
+ componentName: 'NeverInteracted',
278
+ })
279
+
280
+ let loaderCalls = 0
281
+ const cleanup = hydrateIslands({
282
+ NeverInteracted: () => {
283
+ loaderCalls += 1
284
+ return Promise.resolve({ default: () => <div>x</div> })
285
+ },
286
+ })
287
+
288
+ await settle()
289
+ expect(island.getAttribute('data-island-state')).toBe('awaiting-interaction')
290
+
291
+ // Remove listeners BEFORE any interaction happens.
292
+ cleanup()
293
+ expect(island.getAttribute('data-island-state')).toBeNull()
294
+
295
+ // Click after cleanup should NOT trigger the loader.
296
+ island.click()
297
+ await settle()
298
+ expect(loaderCalls).toBe(0)
299
+
300
+ cleanupIslands()
301
+ })
302
+
303
+ it('hydrate=never WITHOUT a registry entry stays clean (no data-island-error)', async () => {
304
+ // The whole point of hydrate=never is shipping zero client JS — so the
305
+ // user does NOT register a loader. Pre-fix the missing-loader check
306
+ // fired for never-islands and stamped data-island-error="no-loader",
307
+ // which surfaced as a bogus warning + attribute on legitimate static
308
+ // content. Now never-strategy short-circuits before the registry check.
309
+ const island = installIsland({
310
+ hydrate: 'never',
311
+ componentName: 'NotRegistered',
312
+ })
313
+
314
+ const cleanup = hydrateIslands({})
315
+
316
+ await settle()
317
+ expect(island.getAttribute('data-island-error')).toBeNull()
318
+ // SSR children remain (they would anyway since never-strategy never
319
+ // mounts; the assertion catches a regression where we'd still write
320
+ // data-island-error even though we don't render).
321
+ expect(island.querySelector('[data-testid="ssr-children"]')?.textContent).toBe(
322
+ 'SSR children',
323
+ )
324
+
325
+ cleanup()
326
+ cleanupIslands()
327
+ })
328
+
329
+ it('flags nested islands with data-island-error="nested" and skips them', async () => {
330
+ const outer = document.createElement('pyreon-island')
331
+ outer.setAttribute('data-component', 'Outer')
332
+ outer.setAttribute('data-props', '{}')
333
+ outer.setAttribute('data-hydrate', 'load')
334
+
335
+ const inner = document.createElement('pyreon-island')
336
+ inner.setAttribute('data-component', 'Inner')
337
+ inner.setAttribute('data-props', '{}')
338
+ inner.setAttribute('data-hydrate', 'load')
339
+ outer.appendChild(inner)
340
+ document.body.appendChild(outer)
341
+
342
+ let innerLoaded = false
343
+ const cleanup = hydrateIslands({
344
+ Outer: () => Promise.resolve({ default: () => <div data-testid="outer">outer</div> }),
345
+ Inner: () => {
346
+ innerLoaded = true
347
+ return Promise.resolve({ default: () => <div>inner</div> })
348
+ },
349
+ })
350
+
351
+ await settle()
352
+ expect(innerLoaded).toBe(false)
353
+ expect(inner.getAttribute('data-island-error')).toBe('nested')
354
+
355
+ cleanup()
356
+ cleanupIslands()
357
+ })
358
+
359
+ it('flags missing-loader islands with data-island-error="no-loader"', async () => {
360
+ const island = installIsland({
361
+ hydrate: 'load',
362
+ componentName: 'Nonexistent',
363
+ })
364
+
365
+ const cleanup = hydrateIslands({})
366
+ await settle()
367
+ expect(island.getAttribute('data-island-error')).toBe('no-loader')
368
+
369
+ cleanup()
370
+ cleanupIslands()
371
+ })
372
+
373
+ it('prefetch=idle: pre-warms the loader BEFORE hydration trigger fires', async () => {
374
+ // Push the island below the fold — `hydrate: 'visible'` would NOT have
375
+ // hydrated it on mount. With `prefetch: 'idle'`, the loader still fires
376
+ // during idle so the chunk is warm by scroll-in.
377
+ const spacer = document.createElement('div')
378
+ spacer.style.height = '5000px'
379
+ document.body.appendChild(spacer)
380
+
381
+ const island = document.createElement('pyreon-island') as HTMLElement
382
+ island.setAttribute('data-component', 'PrefetchedComments')
383
+ island.setAttribute('data-props', '{}')
384
+ island.setAttribute('data-hydrate', 'visible')
385
+ island.setAttribute('data-prefetch', 'idle')
386
+ island.innerHTML = '<span data-testid="ssr-children">SSR children</span>'
387
+ document.body.appendChild(island)
388
+
389
+ let loaderCalls = 0
390
+ const cleanup = hydrateIslands({
391
+ PrefetchedComments: () => {
392
+ loaderCalls++
393
+ return Promise.resolve({ default: () => <div data-testid="comments">x</div> })
394
+ },
395
+ })
396
+
397
+ // Wait for requestIdleCallback to fire. No scrolling yet — the island
398
+ // is still below the fold. With prefetch=idle, the loader MUST run.
399
+ for (let i = 0; i < 30; i++) {
400
+ await flushFrames()
401
+ if (loaderCalls > 0) break
402
+ }
403
+ expect(loaderCalls).toBe(1)
404
+ // But hydration has NOT happened yet — SSR children remain.
405
+ expect(island.querySelector('[data-testid="ssr-children"]')).not.toBeNull()
406
+ expect(island.querySelector('[data-testid="comments"]')).toBeNull()
407
+
408
+ cleanup()
409
+ cleanupIslands()
410
+ spacer.remove()
411
+ })
412
+
413
+ it('prefetch=visible: pre-warms the loader ~200px before viewport entry', async () => {
414
+ const spacer = document.createElement('div')
415
+ spacer.style.height = '5000px'
416
+ document.body.appendChild(spacer)
417
+
418
+ const island = document.createElement('pyreon-island') as HTMLElement
419
+ island.setAttribute('data-component', 'PrefetchVisible')
420
+ island.setAttribute('data-props', '{}')
421
+ // Pair prefetch=visible with hydrate=media(query that won't match) so
422
+ // hydration NEVER fires — isolating the prefetch path. (1px width can't
423
+ // ever be true on a real browser viewport.)
424
+ island.setAttribute('data-hydrate', 'media((max-width: 1px))')
425
+ island.setAttribute('data-prefetch', 'visible')
426
+ island.innerHTML = '<span data-testid="ssr-children">SSR children</span>'
427
+ document.body.appendChild(island)
428
+
429
+ let loaderCalls = 0
430
+ const cleanup = hydrateIslands({
431
+ PrefetchVisible: () => {
432
+ loaderCalls++
433
+ return Promise.resolve({ default: () => <div>x</div> })
434
+ },
435
+ })
436
+
437
+ // Without scroll, the island is below the fold — prefetch=visible's
438
+ // IntersectionObserver hasn't fired yet.
439
+ await settle()
440
+ expect(loaderCalls).toBe(0)
441
+
442
+ // Scroll into view → IntersectionObserver fires → loader runs once.
443
+ island.scrollIntoView({ behavior: 'instant', block: 'center' })
444
+ for (let i = 0; i < 30; i++) {
445
+ await flushFrames()
446
+ if (loaderCalls > 0) break
447
+ }
448
+ expect(loaderCalls).toBe(1)
449
+ // Hydration STILL hasn't happened (media query won't match on a real
450
+ // viewport) — SSR children still in place. This proves prefetch and
451
+ // hydration are independent.
452
+ expect(island.querySelector('[data-testid="ssr-children"]')).not.toBeNull()
453
+
454
+ cleanup()
455
+ cleanupIslands()
456
+ spacer.remove()
457
+ })
458
+
459
+ it('prefetch=none (default / unset): does NOT call loader before hydration trigger', async () => {
460
+ const spacer = document.createElement('div')
461
+ spacer.style.height = '5000px'
462
+ document.body.appendChild(spacer)
463
+
464
+ // No data-prefetch attribute at all — exercises the default-none path.
465
+ const island = installIsland({
466
+ hydrate: 'visible',
467
+ componentName: 'NoPrefetch',
468
+ })
469
+
470
+ let loaderCalls = 0
471
+ const cleanup = hydrateIslands({
472
+ NoPrefetch: () => {
473
+ loaderCalls++
474
+ return Promise.resolve({ default: () => <div>x</div> })
475
+ },
476
+ })
477
+
478
+ await settle()
479
+ // Below the fold + no prefetch → loader must NOT have been called.
480
+ expect(loaderCalls).toBe(0)
481
+ expect(island.querySelector('[data-testid="ssr-children"]')).not.toBeNull()
482
+
483
+ cleanup()
484
+ cleanupIslands()
485
+ spacer.remove()
486
+ })
487
+
488
+ it('flags invalid props JSON with data-island-error="invalid-props"', async () => {
489
+ const island = document.createElement('pyreon-island') as HTMLElement
490
+ island.setAttribute('data-component', 'BadProps')
491
+ island.setAttribute('data-props', '{not-json')
492
+ island.setAttribute('data-hydrate', 'load')
493
+ document.body.appendChild(island)
494
+
495
+ let loaderRan = false
496
+ const cleanup = hydrateIslands({
497
+ BadProps: () => {
498
+ loaderRan = true
499
+ return Promise.resolve({ default: () => <div>x</div> })
500
+ },
501
+ })
502
+
503
+ await settle()
504
+ // The JSON parse error short-circuits BEFORE the loader is called —
505
+ // see hydrateIsland() in src/client.ts. So loaderRan stays false.
506
+ expect(loaderRan).toBe(false)
507
+ expect(island.getAttribute('data-island-error')).toBe('invalid-props')
508
+
509
+ cleanup()
510
+ cleanupIslands()
511
+ })
512
+ })
@@ -21,6 +21,8 @@ describe('gen-docs — server snapshot', () => {
21
21
  const record = renderApiReferenceEntries(manifest)
22
22
  expect(Object.keys(record).sort()).toEqual([
23
23
  'server/createHandler',
24
+ 'server/hydrateIslands',
25
+ 'server/hydrateIslandsAuto',
24
26
  'server/island',
25
27
  'server/prerender',
26
28
  ])