@pyreon/runtime-dom 0.24.5 → 0.24.6
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/package.json +5 -9
- package/src/delegate.ts +0 -98
- package/src/devtools.ts +0 -339
- package/src/env.d.ts +0 -6
- package/src/hydrate.ts +0 -450
- package/src/hydration-debug.ts +0 -129
- package/src/index.ts +0 -83
- package/src/keep-alive-entry.ts +0 -3
- package/src/keep-alive.ts +0 -83
- package/src/manifest.ts +0 -236
- package/src/mount.ts +0 -597
- package/src/nodes.ts +0 -896
- package/src/props.ts +0 -474
- package/src/template.ts +0 -523
- package/src/tests/callback-ref-unmount.browser.test.ts +0 -62
- package/src/tests/callback-ref-unmount.test.ts +0 -52
- package/src/tests/compiler-integration.test.tsx +0 -508
- package/src/tests/coverage-gaps.test.ts +0 -3183
- package/src/tests/coverage.test.ts +0 -1140
- package/src/tests/ctx-stack-growth-repro.test.tsx +0 -158
- package/src/tests/dev-gate-pattern.test.ts +0 -46
- package/src/tests/dev-gate-treeshake.test.ts +0 -256
- package/src/tests/error-boundary-stack-leak-repro.test.tsx +0 -133
- package/src/tests/fanout-repro.test.tsx +0 -219
- package/src/tests/hydration-integration.test.tsx +0 -540
- package/src/tests/keyed-array-in-for-batched-toggle.browser.test.ts +0 -140
- package/src/tests/lifecycle-integration.test.tsx +0 -342
- package/src/tests/lis-prepend.browser.test.ts +0 -99
- package/src/tests/manifest-snapshot.test.ts +0 -85
- package/src/tests/mount.test.ts +0 -3529
- package/src/tests/native-markers.test.ts +0 -19
- package/src/tests/props.test.ts +0 -581
- package/src/tests/reactive-props.test.ts +0 -270
- package/src/tests/real-world-integration.test.tsx +0 -714
- package/src/tests/rs-collapse-dyn-h.browser.test.ts +0 -303
- package/src/tests/rs-collapse-dyn.browser.test.ts +0 -316
- package/src/tests/rs-collapse-h.browser.test.ts +0 -152
- package/src/tests/rs-collapse-h.test.ts +0 -237
- package/src/tests/rs-collapse.browser.test.ts +0 -128
- package/src/tests/runtime-dom.browser.test.ts +0 -409
- package/src/tests/setup.ts +0 -3
- package/src/tests/show-context.test.ts +0 -270
- package/src/tests/show-of-for-batched-toggle.browser.test.ts +0 -122
- package/src/tests/ssr-xss-round-trip.browser.test.ts +0 -93
- package/src/tests/style-key-removal.browser.test.ts +0 -54
- package/src/tests/style-key-removal.test.ts +0 -88
- package/src/tests/template.test.ts +0 -383
- package/src/tests/transition-timeout-leak.test.ts +0 -126
- package/src/tests/transition.test.ts +0 -568
- package/src/tests/verified-correct-probes.test.ts +0 -56
- package/src/transition-entry.ts +0 -7
- package/src/transition-group.ts +0 -350
- package/src/transition.ts +0 -245
|
@@ -1,540 +0,0 @@
|
|
|
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 { _rp, 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
|
-
})
|
|
376
|
-
|
|
377
|
-
// ─── onHydrationMismatch telemetry hook ────────────────────────────────────
|
|
378
|
-
//
|
|
379
|
-
// Pre-fix: runtime-dom emitted hydration mismatches via console.warn ONLY,
|
|
380
|
-
// gated on __DEV__. Production deployments (Sentry, Datadog) had no
|
|
381
|
-
// integration point — mismatches surfaced as silent recovery (text
|
|
382
|
-
// rewritten or DOM remounted) with no telemetry signal. The asymmetry
|
|
383
|
-
// with `@pyreon/core`'s `registerErrorHandler` (which captures component
|
|
384
|
-
// + reactivity errors via the `__pyreon_report_error__` bridge) was the
|
|
385
|
-
// gap.
|
|
386
|
-
//
|
|
387
|
-
// Post-fix: `onHydrationMismatch(handler)` registers a callback fired on
|
|
388
|
-
// EVERY mismatch in dev AND prod, independent of the warn toggle.
|
|
389
|
-
// Mirrors core's `registerErrorHandler` shape.
|
|
390
|
-
describe('hydration integration — onHydrationMismatch telemetry hook', () => {
|
|
391
|
-
test('handler fires with full mismatch context on tag mismatch', async () => {
|
|
392
|
-
const { onHydrationMismatch } = await import('../hydration-debug')
|
|
393
|
-
const captured: Array<{ type: string; expected: unknown; actual: unknown; path: string; timestamp: number }> = []
|
|
394
|
-
const unsub = onHydrationMismatch((ctx) => {
|
|
395
|
-
captured.push({
|
|
396
|
-
type: ctx.type,
|
|
397
|
-
expected: ctx.expected,
|
|
398
|
-
actual: ctx.actual,
|
|
399
|
-
path: ctx.path,
|
|
400
|
-
timestamp: ctx.timestamp,
|
|
401
|
-
})
|
|
402
|
-
})
|
|
403
|
-
|
|
404
|
-
const el = container()
|
|
405
|
-
el.innerHTML = '<div>server content</div>'
|
|
406
|
-
|
|
407
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
408
|
-
const cleanup = hydrateRoot(el, h('span', null, 'client content'))
|
|
409
|
-
|
|
410
|
-
expect(captured.length).toBeGreaterThan(0)
|
|
411
|
-
const tagMismatch = captured.find((c) => c.type === 'tag')
|
|
412
|
-
expect(tagMismatch).toBeDefined()
|
|
413
|
-
expect(tagMismatch?.expected).toBe('span')
|
|
414
|
-
expect(typeof tagMismatch?.path).toBe('string')
|
|
415
|
-
expect(typeof tagMismatch?.timestamp).toBe('number')
|
|
416
|
-
|
|
417
|
-
cleanup()
|
|
418
|
-
unsub()
|
|
419
|
-
warnSpy.mockRestore()
|
|
420
|
-
})
|
|
421
|
-
|
|
422
|
-
test('handler fires for tag mismatch in production-style silence (warn disabled)', () => {
|
|
423
|
-
const el = container()
|
|
424
|
-
el.innerHTML = '<div>server content</div>'
|
|
425
|
-
|
|
426
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
427
|
-
disableHydrationWarnings() // simulate production: warns silenced
|
|
428
|
-
|
|
429
|
-
return import('../hydration-debug').then(({ onHydrationMismatch }) => {
|
|
430
|
-
const captured: Array<{ type: string }> = []
|
|
431
|
-
const unsub = onHydrationMismatch((ctx) => {
|
|
432
|
-
captured.push({ type: ctx.type })
|
|
433
|
-
})
|
|
434
|
-
|
|
435
|
-
const cleanup = hydrateRoot(el, h('span', null, 'client content'))
|
|
436
|
-
|
|
437
|
-
// Telemetry hook fired even with warn disabled — independent.
|
|
438
|
-
expect(captured.length).toBeGreaterThan(0)
|
|
439
|
-
expect(captured.some((c) => c.type === 'tag')).toBe(true)
|
|
440
|
-
// console.warn was NOT called (production-style silence).
|
|
441
|
-
expect(warnSpy).not.toHaveBeenCalled()
|
|
442
|
-
|
|
443
|
-
cleanup()
|
|
444
|
-
unsub()
|
|
445
|
-
warnSpy.mockRestore()
|
|
446
|
-
enableHydrationWarnings()
|
|
447
|
-
})
|
|
448
|
-
})
|
|
449
|
-
|
|
450
|
-
test('multiple handlers all receive forwarded mismatches; unsub stops one cleanly', async () => {
|
|
451
|
-
const { onHydrationMismatch } = await import('../hydration-debug')
|
|
452
|
-
let count1 = 0
|
|
453
|
-
let count2 = 0
|
|
454
|
-
const unsub1 = onHydrationMismatch(() => count1++)
|
|
455
|
-
const unsub2 = onHydrationMismatch(() => count2++)
|
|
456
|
-
|
|
457
|
-
const el = container()
|
|
458
|
-
el.innerHTML = '<div>server</div>'
|
|
459
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
460
|
-
|
|
461
|
-
const cleanup = hydrateRoot(el, h('span', null, 'client'))
|
|
462
|
-
|
|
463
|
-
expect(count1).toBeGreaterThan(0)
|
|
464
|
-
expect(count1).toBe(count2)
|
|
465
|
-
|
|
466
|
-
// Unsubscribe one — only the other fires next time.
|
|
467
|
-
unsub1()
|
|
468
|
-
const before2 = count2
|
|
469
|
-
const el2 = container()
|
|
470
|
-
el2.innerHTML = '<p>foo</p>'
|
|
471
|
-
const cleanup2 = hydrateRoot(el2, h('article', null, 'bar'))
|
|
472
|
-
|
|
473
|
-
expect(count2).toBeGreaterThan(before2)
|
|
474
|
-
|
|
475
|
-
cleanup()
|
|
476
|
-
cleanup2()
|
|
477
|
-
unsub2()
|
|
478
|
-
warnSpy.mockRestore()
|
|
479
|
-
})
|
|
480
|
-
|
|
481
|
-
test('handler errors do not propagate into hydration', async () => {
|
|
482
|
-
const { onHydrationMismatch } = await import('../hydration-debug')
|
|
483
|
-
let goodHandlerFired = false
|
|
484
|
-
const unsubBad = onHydrationMismatch(() => {
|
|
485
|
-
throw new Error('telemetry SDK exploded')
|
|
486
|
-
})
|
|
487
|
-
const unsubGood = onHydrationMismatch(() => {
|
|
488
|
-
goodHandlerFired = true
|
|
489
|
-
})
|
|
490
|
-
|
|
491
|
-
const el = container()
|
|
492
|
-
el.innerHTML = '<div>server</div>'
|
|
493
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
494
|
-
disableHydrationWarnings()
|
|
495
|
-
|
|
496
|
-
// Hydration must complete without throwing despite bad handler.
|
|
497
|
-
const cleanup = hydrateRoot(el, h('span', null, 'client'))
|
|
498
|
-
expect(goodHandlerFired).toBe(true)
|
|
499
|
-
// Client content still rendered — recovery worked.
|
|
500
|
-
expect(el.textContent).toContain('client')
|
|
501
|
-
|
|
502
|
-
cleanup()
|
|
503
|
-
unsubBad()
|
|
504
|
-
unsubGood()
|
|
505
|
-
warnSpy.mockRestore()
|
|
506
|
-
enableHydrationWarnings()
|
|
507
|
-
})
|
|
508
|
-
})
|
|
509
|
-
|
|
510
|
-
// ─── _rp prop forwarding through SSR -> hydrate ─────────────────────────────
|
|
511
|
-
|
|
512
|
-
describe('hydration integration — `_rp`-wrapped component props (regression)', () => {
|
|
513
|
-
// Pre-fix, hydrate.ts skipped `makeReactiveProps` on the way into a
|
|
514
|
-
// component, so `props.x` returned the raw `_rp` function instead of the
|
|
515
|
-
// resolved value. mount.ts already did the right thing, so the failure mode
|
|
516
|
-
// surfaced only on cold-start SSR/hydrate (the fundamentals NavItem layout
|
|
517
|
-
// shape — see e2e/fundamentals/playground.spec.ts). Lock in BOTH the SSR
|
|
518
|
-
// emit and the post-hydration value.
|
|
519
|
-
test('SSR emits resolved string from `_rp` prop, hydration preserves it', async () => {
|
|
520
|
-
const Link = (props: { to: string }) =>
|
|
521
|
-
h('a', { href: `#${props.to}`, id: 'lnk' }, () => props.to)
|
|
522
|
-
|
|
523
|
-
const html = await renderToString(
|
|
524
|
-
h(Link, { to: _rp(() => '/about') as unknown as string }),
|
|
525
|
-
)
|
|
526
|
-
expect(html).toBe('<a href="#/about" id="lnk">/about</a>')
|
|
527
|
-
expect(html).not.toContain('=>')
|
|
528
|
-
|
|
529
|
-
const el = container()
|
|
530
|
-
el.innerHTML = html
|
|
531
|
-
const cleanup = hydrateRoot(
|
|
532
|
-
el,
|
|
533
|
-
h(Link, { to: _rp(() => '/about') as unknown as string }),
|
|
534
|
-
)
|
|
535
|
-
const link = el.querySelector<HTMLAnchorElement>('#lnk')!
|
|
536
|
-
expect(link.getAttribute('href')).toBe('#/about')
|
|
537
|
-
expect(link.textContent).toBe('/about')
|
|
538
|
-
cleanup()
|
|
539
|
-
})
|
|
540
|
-
})
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
import { For, h } from '@pyreon/core'
|
|
2
|
-
import type { ForProps, VNode } from '@pyreon/core'
|
|
3
|
-
import { signal } from '@pyreon/reactivity'
|
|
4
|
-
import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
|
|
5
|
-
import { describe, expect, it } from 'vitest'
|
|
6
|
-
|
|
7
|
-
// Regression — same closure-captured-parent bug class as the For-of-Show
|
|
8
|
-
// fix in #776, but exercising the SIBLING reactive entry point
|
|
9
|
-
// `mountKeyedList` (the inline reactive keyed array shape).
|
|
10
|
-
//
|
|
11
|
-
// **Bug class (recap from #776):** any reactive mount loop that captures
|
|
12
|
-
// `parent` in its setup closure breaks when a containing reconciler
|
|
13
|
-
// creates the subtree in a `DocumentFragment` and then moves it via
|
|
14
|
-
// `liveParent.insertBefore(frag, tailMarker)`. The markers move with
|
|
15
|
-
// the fragment contents; the captured `parent` becomes a stale
|
|
16
|
-
// reference to the now-empty fragment. Next signal flip → throw +
|
|
17
|
-
// child-loss.
|
|
18
|
-
//
|
|
19
|
-
// **`mountKeyedList`'s exposure**: three sites in `nodes.ts:mountKeyedList`
|
|
20
|
-
// use closure-captured `parent`:
|
|
21
|
-
// 1. `parent.insertBefore(anchor, tailMarker)` in mountNewEntries
|
|
22
|
-
// 2. `mountVNode(vnode, parent, tailMarker)` immediately after
|
|
23
|
-
// 3. `keyedListReorder(..., parent, tailMarker)` → applyKeyedMoves
|
|
24
|
-
// → moveEntryBefore → `parent.insertBefore(node, before)`
|
|
25
|
-
//
|
|
26
|
-
// All three run inside the `effect(() => ...)` body, so any post-setup
|
|
27
|
-
// move of the markers (via a containing mountFor's frag-then-move) plus
|
|
28
|
-
// a subsequent signal-driven re-run (mount new entries OR reorder)
|
|
29
|
-
// throws NotFoundError.
|
|
30
|
-
//
|
|
31
|
-
// **Trigger requires the keyed array to land DIRECTLY in the For's
|
|
32
|
-
// fragment** (no wrapping Element). The cleanest shape: For children
|
|
33
|
-
// returns a function `(i) => () => signal().map(...)` — mountFor's
|
|
34
|
-
// renderInto calls `mountChild(fn, frag, before)`, mountChild's
|
|
35
|
-
// function branch samples the result, sees a keyed array, and creates
|
|
36
|
-
// `mountKeyedList(fn, frag, before, ...)`. Now `parent === frag` in
|
|
37
|
-
// the closure, and the bug fires identically to the For-of-Show case.
|
|
38
|
-
// A `<div>`-wrapped keyed array would NOT trigger the bug — the div
|
|
39
|
-
// is the element parent, isolating mountKeyedList from the frag move.
|
|
40
|
-
|
|
41
|
-
describe('mountKeyedList: inline keyed array as direct For child under batched signal additions', () => {
|
|
42
|
-
it('sanity: top-level inline keyed array handles add/reorder cycles correctly', async () => {
|
|
43
|
-
// No For wrapper — mountKeyedList is the top-level reconciler.
|
|
44
|
-
// Should always work — no frag-then-move pressure on the captured parent.
|
|
45
|
-
const items = signal<{ id: number }[]>([{ id: 0 }])
|
|
46
|
-
const { container, unmount } = mountInBrowser(
|
|
47
|
-
h(
|
|
48
|
-
'div',
|
|
49
|
-
{ id: 'root' },
|
|
50
|
-
() => items().map((v) => h('span', { key: v.id, 'data-id': String(v.id) }, String(v.id))),
|
|
51
|
-
),
|
|
52
|
-
)
|
|
53
|
-
await flush()
|
|
54
|
-
expect(container.querySelectorAll('span[data-id]')).toHaveLength(1)
|
|
55
|
-
|
|
56
|
-
items.set([{ id: 0 }, { id: 1 }, { id: 2 }])
|
|
57
|
-
await flush()
|
|
58
|
-
expect(container.querySelectorAll('span[data-id]')).toHaveLength(3)
|
|
59
|
-
|
|
60
|
-
// Reorder — exercises mountKeyedList's keyedListReorder path
|
|
61
|
-
items.set([{ id: 2 }, { id: 0 }, { id: 1 }])
|
|
62
|
-
await flush()
|
|
63
|
-
expect(container.querySelectorAll('span[data-id]')).toHaveLength(3)
|
|
64
|
-
|
|
65
|
-
items.set([])
|
|
66
|
-
await flush()
|
|
67
|
-
expect(container.querySelectorAll('span[data-id]')).toHaveLength(0)
|
|
68
|
-
unmount()
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
it('CONTRACT: <For> + DIRECT keyed-array function child does not throw NotFoundError or lose children', async () => {
|
|
72
|
-
// 10 For rows. Each row's children function returns a FUNCTION
|
|
73
|
-
// `() => signal().map(...)` directly — NOT wrapped in a `<div>`.
|
|
74
|
-
// mountFor's renderInto then calls `mountChild(fn, frag, before)`,
|
|
75
|
-
// mountChild creates `mountKeyedList(fn, frag, before, ...)` —
|
|
76
|
-
// capturing the For's DocumentFragment as `parent`. Once the frag
|
|
77
|
-
// moves to the live parent, the closure-captured `parent` is stale.
|
|
78
|
-
//
|
|
79
|
-
// The initial signal value MUST be a non-empty keyed array for
|
|
80
|
-
// `isKeyedArray(sample)` (mount.ts) to route into mountKeyedList;
|
|
81
|
-
// an empty initial array would route into mountReactive instead
|
|
82
|
-
// (already covered by the For-of-Show fix in #776). Each row starts
|
|
83
|
-
// with one item so we land on mountKeyedList, then we grow each
|
|
84
|
-
// row's array — mountKeyedList's mountNewEntries calls
|
|
85
|
-
// `parent.insertBefore(anchor, tailMarker)` which throws against
|
|
86
|
-
// the stale frag unless the live-parent fix is applied.
|
|
87
|
-
const itemSignals = Array.from({ length: 10 }, (_, rowIdx) =>
|
|
88
|
-
signal<{ id: number }[]>([{ id: rowIdx * 100 }]),
|
|
89
|
-
)
|
|
90
|
-
const indices = Array.from({ length: 10 }, (_, i) => i)
|
|
91
|
-
|
|
92
|
-
// Cast: ForProps.children narrows to `(item: T) => VNode | NativeItem`,
|
|
93
|
-
// but the runtime ALSO accepts a function return (mount.ts's mountChild
|
|
94
|
-
// function branch handles it). This shape is exactly what triggers
|
|
95
|
-
// mountKeyedList with frag-as-parent — the public type doesn't expose
|
|
96
|
-
// it, so we cast through an explicit ForProps<number> shape.
|
|
97
|
-
const forProps: ForProps<number> = {
|
|
98
|
-
each: indices,
|
|
99
|
-
by: (i: number) => i,
|
|
100
|
-
// children returns a FUNCTION (not a VNode). That function returns
|
|
101
|
-
// a keyed array — mountChild's function branch routes it to
|
|
102
|
-
// mountKeyedList with frag as parent.
|
|
103
|
-
children: ((rowIdx: number) =>
|
|
104
|
-
() =>
|
|
105
|
-
(itemSignals[rowIdx] as ReturnType<typeof signal<{ id: number }[]>>)().map((v) =>
|
|
106
|
-
h('span', { key: v.id, 'data-rowitem': `${rowIdx}-${v.id}` }, String(v.id)),
|
|
107
|
-
)) as unknown as (item: number) => VNode,
|
|
108
|
-
}
|
|
109
|
-
const { container, unmount } = mountInBrowser(
|
|
110
|
-
h('div', { id: 'root' }, For(forProps)),
|
|
111
|
-
)
|
|
112
|
-
await flush()
|
|
113
|
-
try {
|
|
114
|
-
// Sanity: one item per row at mount, 10 total.
|
|
115
|
-
expect(container.querySelectorAll('span[data-rowitem]')).toHaveLength(10)
|
|
116
|
-
|
|
117
|
-
// Grow every row's array to 5 items each — 10 rows × 5 = 50.
|
|
118
|
-
// Bug fires HERE on EVERY row: mountKeyedList's mountNewEntries
|
|
119
|
-
// calls `parent.insertBefore(anchor, tailMarker)` against the
|
|
120
|
-
// stale frag captured at the For's initial mount.
|
|
121
|
-
for (let r = 0; r < 10; r++) {
|
|
122
|
-
const s = itemSignals[r] as ReturnType<typeof signal<{ id: number }[]>>
|
|
123
|
-
s.set([0, 1, 2, 3, 4].map((id) => ({ id: r * 100 + id })))
|
|
124
|
-
}
|
|
125
|
-
await flush()
|
|
126
|
-
|
|
127
|
-
expect(container.querySelectorAll('span[data-rowitem]')).toHaveLength(50)
|
|
128
|
-
|
|
129
|
-
// Reorder one row — exercises mountKeyedList's reorder path
|
|
130
|
-
// (keyedListReorder → applyKeyedMoves → moveEntryBefore → stale
|
|
131
|
-
// `parent.insertBefore(startNode, before)`). All items must remain.
|
|
132
|
-
const s0 = itemSignals[0] as ReturnType<typeof signal<{ id: number }[]>>
|
|
133
|
-
s0.set([4, 0, 2, 1, 3].map((id) => ({ id: id })))
|
|
134
|
-
await flush()
|
|
135
|
-
expect(container.querySelectorAll('span[data-rowitem]')).toHaveLength(50)
|
|
136
|
-
} finally {
|
|
137
|
-
unmount()
|
|
138
|
-
}
|
|
139
|
-
})
|
|
140
|
-
})
|