@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.
Files changed (53) hide show
  1. package/package.json +5 -9
  2. package/src/delegate.ts +0 -98
  3. package/src/devtools.ts +0 -339
  4. package/src/env.d.ts +0 -6
  5. package/src/hydrate.ts +0 -450
  6. package/src/hydration-debug.ts +0 -129
  7. package/src/index.ts +0 -83
  8. package/src/keep-alive-entry.ts +0 -3
  9. package/src/keep-alive.ts +0 -83
  10. package/src/manifest.ts +0 -236
  11. package/src/mount.ts +0 -597
  12. package/src/nodes.ts +0 -896
  13. package/src/props.ts +0 -474
  14. package/src/template.ts +0 -523
  15. package/src/tests/callback-ref-unmount.browser.test.ts +0 -62
  16. package/src/tests/callback-ref-unmount.test.ts +0 -52
  17. package/src/tests/compiler-integration.test.tsx +0 -508
  18. package/src/tests/coverage-gaps.test.ts +0 -3183
  19. package/src/tests/coverage.test.ts +0 -1140
  20. package/src/tests/ctx-stack-growth-repro.test.tsx +0 -158
  21. package/src/tests/dev-gate-pattern.test.ts +0 -46
  22. package/src/tests/dev-gate-treeshake.test.ts +0 -256
  23. package/src/tests/error-boundary-stack-leak-repro.test.tsx +0 -133
  24. package/src/tests/fanout-repro.test.tsx +0 -219
  25. package/src/tests/hydration-integration.test.tsx +0 -540
  26. package/src/tests/keyed-array-in-for-batched-toggle.browser.test.ts +0 -140
  27. package/src/tests/lifecycle-integration.test.tsx +0 -342
  28. package/src/tests/lis-prepend.browser.test.ts +0 -99
  29. package/src/tests/manifest-snapshot.test.ts +0 -85
  30. package/src/tests/mount.test.ts +0 -3529
  31. package/src/tests/native-markers.test.ts +0 -19
  32. package/src/tests/props.test.ts +0 -581
  33. package/src/tests/reactive-props.test.ts +0 -270
  34. package/src/tests/real-world-integration.test.tsx +0 -714
  35. package/src/tests/rs-collapse-dyn-h.browser.test.ts +0 -303
  36. package/src/tests/rs-collapse-dyn.browser.test.ts +0 -316
  37. package/src/tests/rs-collapse-h.browser.test.ts +0 -152
  38. package/src/tests/rs-collapse-h.test.ts +0 -237
  39. package/src/tests/rs-collapse.browser.test.ts +0 -128
  40. package/src/tests/runtime-dom.browser.test.ts +0 -409
  41. package/src/tests/setup.ts +0 -3
  42. package/src/tests/show-context.test.ts +0 -270
  43. package/src/tests/show-of-for-batched-toggle.browser.test.ts +0 -122
  44. package/src/tests/ssr-xss-round-trip.browser.test.ts +0 -93
  45. package/src/tests/style-key-removal.browser.test.ts +0 -54
  46. package/src/tests/style-key-removal.test.ts +0 -88
  47. package/src/tests/template.test.ts +0 -383
  48. package/src/tests/transition-timeout-leak.test.ts +0 -126
  49. package/src/tests/transition.test.ts +0 -568
  50. package/src/tests/verified-correct-probes.test.ts +0 -56
  51. package/src/transition-entry.ts +0 -7
  52. package/src/transition-group.ts +0 -350
  53. 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
- })