@pyreon/runtime-dom 0.13.1 → 0.14.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.
@@ -0,0 +1,99 @@
1
+ import { For, h } from '@pyreon/core'
2
+ import { signal } from '@pyreon/reactivity'
3
+ import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ // Real-Chromium coverage for the `computeForLis` known-slot fast path.
7
+ // happy-dom is fine for op-counting the LIS probes but doesn't prove the
8
+ // reconciler actually lays out the DOM in the right order after a prepend.
9
+ // A bug in the fast path would silently corrupt the DOM under real CSS
10
+ // layout — caught here, not in the vitest happy-dom suite.
11
+
12
+ function buildRows(count: number, offset = 0): Array<{ id: number; label: string }> {
13
+ return Array.from({ length: count }, (_, i) => ({
14
+ id: i + offset,
15
+ label: `row-${i + offset}`,
16
+ }))
17
+ }
18
+
19
+ describe('runtime-dom LIS prepend fast path', () => {
20
+ it('prepends 100 rows to a 100-row list, DOM matches the signal order', async () => {
21
+ type Row = { id: number; label: string }
22
+ const rows = signal<Row[]>(buildRows(100, 0))
23
+ const { container, unmount } = mountInBrowser(
24
+ h(
25
+ 'ul',
26
+ { id: 'list' },
27
+ For({
28
+ each: rows,
29
+ by: (r: Row) => r.id,
30
+ children: (r: Row) => h('li', { 'data-id': String(r.id) }, r.label),
31
+ }),
32
+ ),
33
+ )
34
+
35
+ let items = container.querySelectorAll<HTMLLIElement>('#list li')
36
+ expect(items).toHaveLength(100)
37
+ expect(items[0]?.dataset.id).toBe('0')
38
+ expect(items[99]?.dataset.id).toBe('99')
39
+
40
+ // Prepend 100 new rows with ids 100..199. Final list: [100..199, 0..99].
41
+ const prepended = buildRows(100, 100)
42
+ rows.set([...prepended, ...rows()])
43
+ await flush()
44
+
45
+ items = container.querySelectorAll<HTMLLIElement>('#list li')
46
+ expect(items).toHaveLength(200)
47
+ // First 100 items must be the prepended rows in order.
48
+ expect(items[0]?.dataset.id).toBe('100')
49
+ expect(items[99]?.dataset.id).toBe('199')
50
+ // Next 100 must be the original rows in original order.
51
+ expect(items[100]?.dataset.id).toBe('0')
52
+ expect(items[199]?.dataset.id).toBe('99')
53
+ unmount()
54
+ })
55
+
56
+ it('measured prepend wall-clock stays in the expected range', async () => {
57
+ // HONEST framing: the LIS fast path saves ~50-100 µs on a 1k prepend.
58
+ // The full prepend cost is dominated by DOM work (~5-20 ms in real
59
+ // Chromium for 1k <li> nodes). This test measures the full wall-clock
60
+ // to give a realistic upper bound — the LIS save is a single-digit
61
+ // percent improvement, not a headline win.
62
+ //
63
+ // Assertion bound is intentionally loose (< 500 ms). The purpose is
64
+ // to anchor a "is this catastrophically slow" ceiling, not to prove
65
+ // the LIS fix is responsible for any particular chunk of time.
66
+ type Row = { id: number; label: string }
67
+ const rows = signal<Row[]>(buildRows(1000, 0))
68
+ const { container, unmount } = mountInBrowser(
69
+ h(
70
+ 'ul',
71
+ { id: 'perf-list' },
72
+ For({
73
+ each: rows,
74
+ by: (r: Row) => r.id,
75
+ children: (r: Row) => h('li', { 'data-id': String(r.id) }, r.label),
76
+ }),
77
+ ),
78
+ )
79
+
80
+ // Warm up — first mount allocates backing arrays.
81
+ await flush()
82
+ expect(container.querySelectorAll('#perf-list li')).toHaveLength(1000)
83
+
84
+ const prepended = buildRows(1000, 1000)
85
+ const t0 = performance.now()
86
+ rows.set([...prepended, ...rows()])
87
+ await flush()
88
+ const elapsed = performance.now() - t0
89
+
90
+ // oxlint-disable-next-line no-console
91
+ console.log(`[lis-prepend] 1k→2k prepend wall-clock: ${elapsed.toFixed(2)}ms`)
92
+
93
+ expect(container.querySelectorAll('#perf-list li')).toHaveLength(2000)
94
+ // Loose ceiling. Real Chromium typically lands at 10-50ms. We're not
95
+ // asserting the LIS win — we're asserting the whole path didn't break.
96
+ expect(elapsed).toBeLessThan(500)
97
+ unmount()
98
+ })
99
+ })
@@ -1,4 +1,4 @@
1
- import { For, h, Portal } from '@pyreon/core'
1
+ import { For, h, Portal, _rp } from '@pyreon/core'
2
2
  import { signal } from '@pyreon/reactivity'
3
3
  import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
4
4
  import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -63,6 +63,68 @@ describe('runtime-dom in real browser', () => {
63
63
  unmount()
64
64
  })
65
65
 
66
+ it('keyed <For> with _rp-wrapped each (compiled JSX shape) renders + reacts to signal updates', async () => {
67
+ // Regression for the `<For each={signal}>` JSX form. The compiler emits
68
+ // `h(For, { each: _rp(() => rows()), ... })`. `makeReactiveProps` (via
69
+ // mountComponent) converts the `_rp`-branded function to a getter on
70
+ // `props.each`. `For()` then forwards those props onto a `ForSymbol`
71
+ // VNode, which reaches `mountChild`'s ForSymbol branch.
72
+ //
73
+ // Before the fix, that branch destructured `{ each, by, children }`
74
+ // eagerly — firing the getter and binding `each` to the *resolved
75
+ // array*, not the function. `mountFor` then crashed on `source()`
76
+ // (calling an array) and the list never rendered. This test produces
77
+ // the exact same vnode shape the compiler emits, so it catches the
78
+ // regression without needing a JSX transform pipeline in the test.
79
+ type Row = { id: number; label: string }
80
+ const rows = signal<Row[]>([
81
+ { id: 1, label: 'a' },
82
+ { id: 2, label: 'b' },
83
+ { id: 3, label: 'c' },
84
+ ])
85
+
86
+ const { container, unmount } = mountInBrowser(
87
+ h(
88
+ 'ul',
89
+ { id: 'rp-list' },
90
+ h(For, {
91
+ each: _rp(() => rows()),
92
+ by: (r: Row) => r.id,
93
+ children: (r: Row) => h('li', { 'data-id': String(r.id) }, r.label),
94
+ }),
95
+ ),
96
+ )
97
+
98
+ let items = container.querySelectorAll<HTMLLIElement>('#rp-list li')
99
+ expect(items).toHaveLength(3)
100
+ expect(Array.from(items).map((el) => el.dataset.id)).toEqual(['1', '2', '3'])
101
+
102
+ // Signal-driven update — replace + reorder the list.
103
+ rows.set([
104
+ { id: 2, label: 'b' },
105
+ { id: 4, label: 'd' },
106
+ { id: 1, label: 'a' },
107
+ ])
108
+ await flush()
109
+
110
+ items = container.querySelectorAll<HTMLLIElement>('#rp-list li')
111
+ expect(items).toHaveLength(3)
112
+ expect(Array.from(items).map((el) => el.dataset.id)).toEqual(['2', '4', '1'])
113
+ expect(Array.from(items).map((el) => el.textContent)).toEqual(['b', 'd', 'a'])
114
+
115
+ // Append — confirms reactivity persists across multiple updates.
116
+ rows.set([
117
+ { id: 2, label: 'b' },
118
+ { id: 4, label: 'd' },
119
+ { id: 1, label: 'a' },
120
+ { id: 5, label: 'e' },
121
+ ])
122
+ await flush()
123
+ items = container.querySelectorAll<HTMLLIElement>('#rp-list li')
124
+ expect(items).toHaveLength(4)
125
+ unmount()
126
+ })
127
+
66
128
  it('creates SVG with the SVG namespace and updates reactive attributes via setAttribute', async () => {
67
129
  const x = signal(10)
68
130
  const { container, unmount } = mountInBrowser(
@@ -121,6 +121,70 @@ describe('_bindText', () => {
121
121
 
122
122
  dispose()
123
123
  })
124
+
125
+ test('skips DOM write when value unchanged (fast path)', () => {
126
+ const s = signal('same')
127
+ const node = document.createTextNode('')
128
+
129
+ const dispose = _bindText(s, node)
130
+ expect(node.data).toBe('same')
131
+
132
+ // Set same value — should skip the DOM write (next !== node.data is false)
133
+ s.set('same')
134
+ expect(node.data).toBe('same')
135
+
136
+ dispose()
137
+ })
138
+
139
+ test('fallback path: null/false/undefined → empty string', () => {
140
+ const s = signal<string | null | false | undefined>('text')
141
+ const getter = () => s()
142
+ const node = document.createTextNode('')
143
+
144
+ const dispose = _bindText(getter as unknown as Parameters<typeof _bindText>[0], node)
145
+ expect(node.data).toBe('text')
146
+
147
+ s.set(null)
148
+ expect(node.data).toBe('')
149
+
150
+ s.set(false)
151
+ expect(node.data).toBe('')
152
+
153
+ s.set(undefined)
154
+ expect(node.data).toBe('')
155
+
156
+ s.set('restored')
157
+ expect(node.data).toBe('restored')
158
+
159
+ dispose()
160
+ })
161
+
162
+ test('fallback path: skips DOM write when value unchanged', () => {
163
+ const s = signal('x')
164
+ const getter = () => s()
165
+ const node = document.createTextNode('')
166
+
167
+ const dispose = _bindText(getter as unknown as Parameters<typeof _bindText>[0], node)
168
+ expect(node.data).toBe('x')
169
+
170
+ s.set('x') // same value — skip
171
+ expect(node.data).toBe('x')
172
+
173
+ dispose()
174
+ })
175
+
176
+ test('number coercion via String()', () => {
177
+ const s = signal<number>(42)
178
+ const node = document.createTextNode('')
179
+
180
+ const dispose = _bindText(s, node)
181
+ expect(node.data).toBe('42')
182
+
183
+ s.set(0)
184
+ expect(node.data).toBe('0')
185
+
186
+ dispose()
187
+ })
124
188
  })
125
189
 
126
190
  // ─── _bindDirect ────────────────────────────────────────────────────────────
@@ -0,0 +1,7 @@
1
+ // Subpath entry for @pyreon/runtime-dom/transition
2
+ // Apps that don't use animations can avoid importing this entirely.
3
+
4
+ export type { TransitionProps } from './transition'
5
+ export { Transition } from './transition'
6
+ export type { TransitionGroupProps } from './transition-group'
7
+ export { TransitionGroup } from './transition-group'