@pyreon/runtime-dom 0.12.6 → 0.12.8

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
+ })