@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.
- package/README.md +23 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/analysis/keep-alive-entry.js.html +5406 -0
- package/lib/analysis/transition-entry.js.html +5406 -0
- package/lib/index.js +98 -47
- package/lib/index.js.map +1 -1
- package/lib/keep-alive-entry.js +1341 -0
- package/lib/keep-alive-entry.js.map +1 -0
- package/lib/transition-entry.js +167 -0
- package/lib/transition-entry.js.map +1 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/keep-alive-entry.d.ts +41 -0
- package/lib/types/keep-alive-entry.d.ts.map +1 -0
- package/lib/types/transition-entry.d.ts +59 -0
- package/lib/types/transition-entry.d.ts.map +1 -0
- package/package.json +16 -6
- package/src/hydrate.ts +14 -12
- package/src/index.ts +19 -3
- package/src/keep-alive-entry.ts +3 -0
- package/src/mount.ts +159 -54
- package/src/nodes.ts +61 -11
- package/src/template.ts +13 -2
- package/src/tests/coverage-gaps.test.ts +709 -0
- package/src/tests/lis-prepend.browser.test.ts +99 -0
- package/src/tests/runtime-dom.browser.test.ts +63 -1
- package/src/tests/template.test.ts +64 -0
- package/src/transition-entry.ts +7 -0
|
@@ -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'
|