@pyreon/runtime-dom 0.12.7 → 0.12.9
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,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hydration Integration Tests
|
|
3
|
+
*
|
|
4
|
+
* Full SSR -> hydrate pipeline: render on server, put HTML in DOM,
|
|
5
|
+
* hydrate on client, verify signals work and DOM is reused.
|
|
6
|
+
*/
|
|
7
|
+
import type { VNodeChild } from '@pyreon/core'
|
|
8
|
+
import { For, Fragment, h, Show } from '@pyreon/core'
|
|
9
|
+
import { signal } from '@pyreon/reactivity'
|
|
10
|
+
import { renderToString } from '@pyreon/runtime-server'
|
|
11
|
+
import { disableHydrationWarnings, enableHydrationWarnings, hydrateRoot } from '../index'
|
|
12
|
+
|
|
13
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
function container(): HTMLElement {
|
|
16
|
+
const el = document.createElement('div')
|
|
17
|
+
document.body.appendChild(el)
|
|
18
|
+
return el
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
document.body.innerHTML = ''
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
// ─── SSR -> hydrate -> reactive ─────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
describe('hydration integration — SSR -> hydrate -> reactive', () => {
|
|
28
|
+
test('simple text: SSR renders, hydrate attaches, signal updates text', async () => {
|
|
29
|
+
const Comp = (props: { name: () => string }) =>
|
|
30
|
+
h('div', null, () => props.name())
|
|
31
|
+
|
|
32
|
+
// 1. Server render
|
|
33
|
+
const html = await renderToString(h(Comp, { name: () => 'Alice' }))
|
|
34
|
+
expect(html).toContain('Alice')
|
|
35
|
+
|
|
36
|
+
// 2. Put HTML in DOM
|
|
37
|
+
const el = container()
|
|
38
|
+
el.innerHTML = html
|
|
39
|
+
|
|
40
|
+
// 3. Capture existing DOM node
|
|
41
|
+
const originalDiv = el.querySelector('div')!
|
|
42
|
+
|
|
43
|
+
// 4. Hydrate with reactive signal
|
|
44
|
+
const name = signal('Alice')
|
|
45
|
+
const cleanup = hydrateRoot(el, h(Comp, { name: () => name() }))
|
|
46
|
+
|
|
47
|
+
// 5. Verify DOM reused (same element, not remounted)
|
|
48
|
+
expect(el.querySelector('div')).toBe(originalDiv)
|
|
49
|
+
|
|
50
|
+
// 6. Change signal -> DOM updates
|
|
51
|
+
name.set('Bob')
|
|
52
|
+
expect(el.querySelector('div')!.textContent).toBe('Bob')
|
|
53
|
+
|
|
54
|
+
cleanup()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('attributes: SSR renders class, hydrate attaches, signal updates class', async () => {
|
|
58
|
+
const Comp = (props: { active: () => boolean }) =>
|
|
59
|
+
h('div', { class: () => (props.active() ? 'active' : 'inactive') }, 'content')
|
|
60
|
+
|
|
61
|
+
const html = await renderToString(h(Comp, { active: () => true }))
|
|
62
|
+
expect(html).toContain('active')
|
|
63
|
+
|
|
64
|
+
const el = container()
|
|
65
|
+
el.innerHTML = html
|
|
66
|
+
const originalDiv = el.querySelector('div')!
|
|
67
|
+
|
|
68
|
+
const active = signal(true)
|
|
69
|
+
const cleanup = hydrateRoot(el, h(Comp, { active: () => active() }))
|
|
70
|
+
|
|
71
|
+
// DOM reused
|
|
72
|
+
expect(el.querySelector('div')).toBe(originalDiv)
|
|
73
|
+
expect(el.querySelector('div')!.className).toBe('active')
|
|
74
|
+
|
|
75
|
+
// Toggle class reactively
|
|
76
|
+
active.set(false)
|
|
77
|
+
expect(el.querySelector('div')!.className).toBe('inactive')
|
|
78
|
+
|
|
79
|
+
cleanup()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('nested components: SSR renders tree, hydrate reuses DOM', async () => {
|
|
83
|
+
const Inner = (props: { text: () => string }) =>
|
|
84
|
+
h('span', { class: 'inner' }, () => props.text())
|
|
85
|
+
|
|
86
|
+
const Outer = (props: { text: () => string }) =>
|
|
87
|
+
h('div', { class: 'outer' }, h(Inner, { text: props.text }))
|
|
88
|
+
|
|
89
|
+
const html = await renderToString(h(Outer, { text: () => 'hello' }))
|
|
90
|
+
|
|
91
|
+
const el = container()
|
|
92
|
+
el.innerHTML = html
|
|
93
|
+
const originalSpan = el.querySelector('span.inner')!
|
|
94
|
+
|
|
95
|
+
const text = signal('hello')
|
|
96
|
+
const cleanup = hydrateRoot(el, h(Outer, { text: () => text() }))
|
|
97
|
+
|
|
98
|
+
// Span reused from server HTML
|
|
99
|
+
expect(el.querySelector('span.inner')).toBe(originalSpan)
|
|
100
|
+
expect(el.querySelector('span.inner')!.textContent).toBe('hello')
|
|
101
|
+
|
|
102
|
+
// Reactive update through nested component
|
|
103
|
+
text.set('world')
|
|
104
|
+
expect(el.querySelector('span.inner')!.textContent).toBe('world')
|
|
105
|
+
|
|
106
|
+
cleanup()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('Show conditional: SSR renders true branch, hydrate attaches reactivity', async () => {
|
|
110
|
+
const text = signal('visible content')
|
|
111
|
+
|
|
112
|
+
// Show is a reactive component — during hydration, it falls back to
|
|
113
|
+
// mountChild for the reactive boundary. We verify the reactive text
|
|
114
|
+
// still works after hydration.
|
|
115
|
+
const Comp = (props: { text: () => string }) =>
|
|
116
|
+
h('div', null,
|
|
117
|
+
h('p', null, () => props.text()),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
const html = await renderToString(
|
|
121
|
+
h(Comp, { text: () => 'visible content' }),
|
|
122
|
+
)
|
|
123
|
+
expect(html).toContain('visible content')
|
|
124
|
+
|
|
125
|
+
const el = container()
|
|
126
|
+
el.innerHTML = html
|
|
127
|
+
|
|
128
|
+
const cleanup = hydrateRoot(
|
|
129
|
+
el,
|
|
130
|
+
h(Comp, { text: () => text() }),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
// Content visible after hydration
|
|
134
|
+
expect(el.querySelector('p')?.textContent).toBe('visible content')
|
|
135
|
+
|
|
136
|
+
// Reactive text update works after hydration
|
|
137
|
+
text.set('updated content')
|
|
138
|
+
expect(el.querySelector('p')?.textContent).toBe('updated content')
|
|
139
|
+
|
|
140
|
+
cleanup()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('Show component mounted fresh after hydration works reactively', () => {
|
|
144
|
+
const el = container()
|
|
145
|
+
el.innerHTML = '<div></div>'
|
|
146
|
+
|
|
147
|
+
const visible = signal(true)
|
|
148
|
+
const text = signal('hello')
|
|
149
|
+
|
|
150
|
+
// Hydrate with Show — Show is a reactive component, so it remounts fresh
|
|
151
|
+
const cleanup = hydrateRoot(
|
|
152
|
+
el,
|
|
153
|
+
h('div', null,
|
|
154
|
+
h(Show, { when: visible },
|
|
155
|
+
h('p', null, () => text()),
|
|
156
|
+
),
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
// Show renders its child
|
|
161
|
+
expect(el.querySelector('p')?.textContent).toBe('hello')
|
|
162
|
+
|
|
163
|
+
// Text update works
|
|
164
|
+
text.set('world')
|
|
165
|
+
expect(el.querySelector('p')?.textContent).toBe('world')
|
|
166
|
+
|
|
167
|
+
cleanup()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('For list: mount fresh after hydration, add/remove items works', () => {
|
|
171
|
+
// For lists always remount during hydration (can't map keys to DOM
|
|
172
|
+
// without SSR markers). We test that the remounted For is fully
|
|
173
|
+
// reactive for add/remove/reorder operations.
|
|
174
|
+
type Item = { id: number; label: string }
|
|
175
|
+
|
|
176
|
+
const el = container()
|
|
177
|
+
el.innerHTML = '<ul></ul>'
|
|
178
|
+
|
|
179
|
+
const items = signal<Item[]>([
|
|
180
|
+
{ id: 1, label: 'alpha' },
|
|
181
|
+
{ id: 2, label: 'beta' },
|
|
182
|
+
{ id: 3, label: 'gamma' },
|
|
183
|
+
])
|
|
184
|
+
|
|
185
|
+
const cleanup = hydrateRoot(
|
|
186
|
+
el,
|
|
187
|
+
h(
|
|
188
|
+
'ul',
|
|
189
|
+
null,
|
|
190
|
+
For({
|
|
191
|
+
each: items,
|
|
192
|
+
by: (r: Item) => r.id,
|
|
193
|
+
children: (r: Item) => h('li', null, r.label),
|
|
194
|
+
}),
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
// For remounts — renders 3 items inside the <ul>
|
|
199
|
+
const ul = el.querySelector('ul')!
|
|
200
|
+
expect(ul.querySelectorAll('li').length).toBe(3)
|
|
201
|
+
|
|
202
|
+
// Add item
|
|
203
|
+
items.update((list) => [...list, { id: 4, label: 'delta' }])
|
|
204
|
+
expect(ul.querySelectorAll('li').length).toBe(4)
|
|
205
|
+
expect(ul.querySelectorAll('li')[3]?.textContent).toBe('delta')
|
|
206
|
+
|
|
207
|
+
// Remove item
|
|
208
|
+
items.set(items().filter((i) => i.id !== 2))
|
|
209
|
+
expect(ul.querySelectorAll('li').length).toBe(3)
|
|
210
|
+
expect(ul.querySelectorAll('li')[0]?.textContent).toBe('alpha')
|
|
211
|
+
expect(ul.querySelectorAll('li')[1]?.textContent).toBe('gamma')
|
|
212
|
+
expect(ul.querySelectorAll('li')[2]?.textContent).toBe('delta')
|
|
213
|
+
|
|
214
|
+
cleanup()
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
test('multiple reactive children in a single element', async () => {
|
|
218
|
+
const Comp = (props: { first: () => string; last: () => string }) =>
|
|
219
|
+
h('div', null,
|
|
220
|
+
h('span', { class: 'first' }, () => props.first()),
|
|
221
|
+
' ',
|
|
222
|
+
h('span', { class: 'last' }, () => props.last()),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
const html = await renderToString(
|
|
226
|
+
h(Comp, { first: () => 'John', last: () => 'Doe' }),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
const el = container()
|
|
230
|
+
el.innerHTML = html
|
|
231
|
+
|
|
232
|
+
const first = signal('John')
|
|
233
|
+
const last = signal('Doe')
|
|
234
|
+
const cleanup = hydrateRoot(
|
|
235
|
+
el,
|
|
236
|
+
h(Comp, { first: () => first(), last: () => last() }),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
expect(el.querySelector('.first')!.textContent).toBe('John')
|
|
240
|
+
expect(el.querySelector('.last')!.textContent).toBe('Doe')
|
|
241
|
+
|
|
242
|
+
// Update independently
|
|
243
|
+
first.set('Jane')
|
|
244
|
+
expect(el.querySelector('.first')!.textContent).toBe('Jane')
|
|
245
|
+
expect(el.querySelector('.last')!.textContent).toBe('Doe')
|
|
246
|
+
|
|
247
|
+
last.set('Smith')
|
|
248
|
+
expect(el.querySelector('.last')!.textContent).toBe('Smith')
|
|
249
|
+
|
|
250
|
+
cleanup()
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
test('component with event handler after hydration', async () => {
|
|
254
|
+
let clickCount = 0
|
|
255
|
+
|
|
256
|
+
const Comp = () =>
|
|
257
|
+
h('button', {
|
|
258
|
+
onClick: () => { clickCount++ },
|
|
259
|
+
}, 'Click me')
|
|
260
|
+
|
|
261
|
+
const html = await renderToString(h(Comp, null))
|
|
262
|
+
|
|
263
|
+
const el = container()
|
|
264
|
+
el.innerHTML = html
|
|
265
|
+
|
|
266
|
+
const cleanup = hydrateRoot(el, h(Comp, null))
|
|
267
|
+
|
|
268
|
+
// Events attached during hydration
|
|
269
|
+
el.querySelector('button')!.click()
|
|
270
|
+
expect(clickCount).toBe(1)
|
|
271
|
+
|
|
272
|
+
el.querySelector('button')!.click()
|
|
273
|
+
expect(clickCount).toBe(2)
|
|
274
|
+
|
|
275
|
+
cleanup()
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
test('Fragment children hydrate correctly', async () => {
|
|
279
|
+
const Comp = (props: { a: () => string; b: () => string }) =>
|
|
280
|
+
h(Fragment, null,
|
|
281
|
+
h('span', { class: 'a' }, () => props.a()),
|
|
282
|
+
h('span', { class: 'b' }, () => props.b()),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
const html = await renderToString(
|
|
286
|
+
h(Comp, { a: () => 'first', b: () => 'second' }),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
const el = container()
|
|
290
|
+
el.innerHTML = html
|
|
291
|
+
|
|
292
|
+
const a = signal('first')
|
|
293
|
+
const b = signal('second')
|
|
294
|
+
const cleanup = hydrateRoot(
|
|
295
|
+
el,
|
|
296
|
+
h(Comp, { a: () => a(), b: () => b() }),
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
expect(el.querySelector('.a')!.textContent).toBe('first')
|
|
300
|
+
expect(el.querySelector('.b')!.textContent).toBe('second')
|
|
301
|
+
|
|
302
|
+
a.set('updated-a')
|
|
303
|
+
expect(el.querySelector('.a')!.textContent).toBe('updated-a')
|
|
304
|
+
|
|
305
|
+
b.set('updated-b')
|
|
306
|
+
expect(el.querySelector('.b')!.textContent).toBe('updated-b')
|
|
307
|
+
|
|
308
|
+
cleanup()
|
|
309
|
+
})
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
// ─── Mismatch recovery ──────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
describe('hydration integration — mismatch recovery', () => {
|
|
315
|
+
test('text mismatch: SSR has "Alice", client has "Bob" — recovers', async () => {
|
|
316
|
+
const Comp = (props: { name: () => string }) =>
|
|
317
|
+
h('div', null, () => props.name())
|
|
318
|
+
|
|
319
|
+
const html = await renderToString(h(Comp, { name: () => 'Alice' }))
|
|
320
|
+
|
|
321
|
+
const el = container()
|
|
322
|
+
el.innerHTML = html
|
|
323
|
+
|
|
324
|
+
// Hydrate with different value — should update text to match client
|
|
325
|
+
const name = signal('Bob')
|
|
326
|
+
disableHydrationWarnings()
|
|
327
|
+
const cleanup = hydrateRoot(el, h(Comp, { name: () => name() }))
|
|
328
|
+
enableHydrationWarnings()
|
|
329
|
+
|
|
330
|
+
// Client value wins after hydration
|
|
331
|
+
expect(el.querySelector('div')!.textContent).toBe('Bob')
|
|
332
|
+
|
|
333
|
+
// Reactivity works
|
|
334
|
+
name.set('Charlie')
|
|
335
|
+
expect(el.querySelector('div')!.textContent).toBe('Charlie')
|
|
336
|
+
|
|
337
|
+
cleanup()
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
test('tag mismatch: SSR has <div>, client has <span> — remounts', async () => {
|
|
341
|
+
const el = container()
|
|
342
|
+
el.innerHTML = '<div>server content</div>'
|
|
343
|
+
|
|
344
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
345
|
+
|
|
346
|
+
const cleanup = hydrateRoot(el, h('span', null, 'client content'))
|
|
347
|
+
|
|
348
|
+
// Mismatch triggers fresh mount — client content rendered
|
|
349
|
+
expect(el.textContent).toContain('client content')
|
|
350
|
+
|
|
351
|
+
cleanup()
|
|
352
|
+
warnSpy.mockRestore()
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
test('extra server children — hydration still works for matching nodes', async () => {
|
|
356
|
+
// Server rendered more children than client expects
|
|
357
|
+
const el = container()
|
|
358
|
+
el.innerHTML = '<div><span>first</span><span>extra</span></div>'
|
|
359
|
+
|
|
360
|
+
const text = signal('first')
|
|
361
|
+
const cleanup = hydrateRoot(
|
|
362
|
+
el,
|
|
363
|
+
h('div', null, h('span', null, () => text())),
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
// First span hydrated
|
|
367
|
+
expect(el.querySelector('span')!.textContent).toBe('first')
|
|
368
|
+
|
|
369
|
+
// Reactive update works
|
|
370
|
+
text.set('updated')
|
|
371
|
+
expect(el.querySelector('span')!.textContent).toBe('updated')
|
|
372
|
+
|
|
373
|
+
cleanup()
|
|
374
|
+
})
|
|
375
|
+
})
|