@pyreon/server 0.15.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +44 -13
- package/lib/types/client.d.ts +44 -5
- package/lib/types/index.d.ts +9 -9
- package/package.json +11 -8
- package/src/client.ts +340 -11
- package/src/handler.ts +17 -2
- 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 +220 -1
|
@@ -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
|
])
|