@pyreon/runtime-dom 0.12.13 → 0.12.15
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 +13 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +167 -26
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +12 -6
- package/src/hydrate.ts +35 -5
- package/src/mount.ts +8 -2
- package/src/props.ts +101 -22
- package/src/tests/callback-ref-unmount.browser.test.ts +62 -0
- package/src/tests/callback-ref-unmount.test.ts +52 -0
- package/src/tests/dev-gate-pattern.test.ts +40 -0
- package/src/tests/dev-gate-treeshake.test.ts +262 -0
- package/src/tests/mount.test.ts +95 -5
- package/src/tests/props.test.ts +117 -0
- package/src/tests/runtime-dom.browser.test.ts +295 -0
- package/src/tests/ssr-xss-round-trip.browser.test.ts +93 -0
- package/src/tests/style-key-removal.browser.test.ts +54 -0
- package/src/tests/style-key-removal.test.ts +88 -0
- package/src/tests/transition-timeout-leak.test.ts +126 -0
- package/src/tests/verified-correct-probes.test.ts +56 -0
- package/src/transition-group.ts +80 -8
- package/src/transition.ts +46 -3
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { For, h, Portal } from '@pyreon/core'
|
|
2
|
+
import { signal } from '@pyreon/reactivity'
|
|
3
|
+
import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { hydrateRoot, mount, Transition } from '../index'
|
|
6
|
+
|
|
7
|
+
// Real-Chromium smoke suite for @pyreon/runtime-dom. Catches environment-
|
|
8
|
+
// divergence bugs that happy-dom hides: SVG namespace property setters,
|
|
9
|
+
// real PointerEvent sequencing, `import.meta.env.DEV` literal-replacement,
|
|
10
|
+
// and the keyed reconciler under live signal updates.
|
|
11
|
+
|
|
12
|
+
describe('runtime-dom in real browser', () => {
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.restoreAllMocks()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('mounts and patches DOM when a signal updates', async () => {
|
|
18
|
+
const count = signal(0)
|
|
19
|
+
const { container, unmount } = mountInBrowser(
|
|
20
|
+
h('span', { id: 'n' }, () => String(count())),
|
|
21
|
+
)
|
|
22
|
+
expect(container.querySelector('#n')?.textContent).toBe('0')
|
|
23
|
+
|
|
24
|
+
count.set(42)
|
|
25
|
+
await flush()
|
|
26
|
+
expect(container.querySelector('#n')?.textContent).toBe('42')
|
|
27
|
+
unmount()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('keyed <For> reconciler inserts at the right index when a list grows', async () => {
|
|
31
|
+
type Row = { id: number; label: string }
|
|
32
|
+
const rows = signal<Row[]>([
|
|
33
|
+
{ id: 1, label: 'a' },
|
|
34
|
+
{ id: 2, label: 'b' },
|
|
35
|
+
])
|
|
36
|
+
const { container, unmount } = mountInBrowser(
|
|
37
|
+
h(
|
|
38
|
+
'ul',
|
|
39
|
+
{ id: 'list' },
|
|
40
|
+
For({
|
|
41
|
+
each: rows,
|
|
42
|
+
by: (r: Row) => r.id,
|
|
43
|
+
children: (r: Row) => h('li', { 'data-id': String(r.id) }, r.label),
|
|
44
|
+
}),
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
let items = container.querySelectorAll<HTMLLIElement>('#list li')
|
|
49
|
+
expect(items).toHaveLength(2)
|
|
50
|
+
expect(Array.from(items).map((el) => el.textContent)).toEqual(['a', 'b'])
|
|
51
|
+
|
|
52
|
+
rows.set([
|
|
53
|
+
{ id: 1, label: 'a' },
|
|
54
|
+
{ id: 3, label: 'c' },
|
|
55
|
+
{ id: 2, label: 'b' },
|
|
56
|
+
])
|
|
57
|
+
await flush()
|
|
58
|
+
|
|
59
|
+
items = container.querySelectorAll<HTMLLIElement>('#list li')
|
|
60
|
+
expect(items).toHaveLength(3)
|
|
61
|
+
expect(Array.from(items).map((el) => el.dataset.id)).toEqual(['1', '3', '2'])
|
|
62
|
+
expect(Array.from(items).map((el) => el.textContent)).toEqual(['a', 'c', 'b'])
|
|
63
|
+
unmount()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('creates SVG with the SVG namespace and updates reactive attributes via setAttribute', async () => {
|
|
67
|
+
const x = signal(10)
|
|
68
|
+
const { container, unmount } = mountInBrowser(
|
|
69
|
+
h(
|
|
70
|
+
'svg',
|
|
71
|
+
{ id: 'svg', width: '100', height: '100' },
|
|
72
|
+
h('rect', { id: 'r', x: () => x(), y: '0', width: '20', height: '20' }),
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
const svg = container.querySelector('#svg')
|
|
77
|
+
const rect = container.querySelector('#r')
|
|
78
|
+
expect(svg?.namespaceURI).toBe('http://www.w3.org/2000/svg')
|
|
79
|
+
expect(rect?.namespaceURI).toBe('http://www.w3.org/2000/svg')
|
|
80
|
+
// SVGRectElement.x is a read-only SVGAnimatedLength getter — applying
|
|
81
|
+
// via property would crash. setAttribute is the only safe path.
|
|
82
|
+
expect(rect?.getAttribute('x')).toBe('10')
|
|
83
|
+
|
|
84
|
+
x.set(55)
|
|
85
|
+
await flush()
|
|
86
|
+
expect(rect?.getAttribute('x')).toBe('55')
|
|
87
|
+
unmount()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('dispatches a real PointerEvent and fires the onClick handler', async () => {
|
|
91
|
+
const clicks = signal(0)
|
|
92
|
+
const { container, unmount } = mountInBrowser(
|
|
93
|
+
h(
|
|
94
|
+
'button',
|
|
95
|
+
{
|
|
96
|
+
id: 'btn',
|
|
97
|
+
onClick: () => clicks.set(clicks() + 1),
|
|
98
|
+
},
|
|
99
|
+
() => `clicks: ${clicks()}`,
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const btn = container.querySelector<HTMLButtonElement>('#btn')!
|
|
104
|
+
expect(btn.textContent).toBe('clicks: 0')
|
|
105
|
+
|
|
106
|
+
btn.dispatchEvent(
|
|
107
|
+
new PointerEvent('pointerdown', { bubbles: true, pointerType: 'mouse' }),
|
|
108
|
+
)
|
|
109
|
+
btn.dispatchEvent(
|
|
110
|
+
new PointerEvent('pointerup', { bubbles: true, pointerType: 'mouse' }),
|
|
111
|
+
)
|
|
112
|
+
btn.click()
|
|
113
|
+
await flush()
|
|
114
|
+
expect(btn.textContent).toBe('clicks: 1')
|
|
115
|
+
unmount()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('emits the duplicate-key __DEV__ warning under Vite (DEV=true)', async () => {
|
|
119
|
+
// import.meta.env.DEV is true in this dev-mode browser run, which is the
|
|
120
|
+
// exact replacement Vite/Rolldown apply at build-time. The warning must
|
|
121
|
+
// fire here. The companion `runtime-dom.prod-bundle.test.ts` Node test
|
|
122
|
+
// proves the same code path is dead in a prod bundle (DEV=false).
|
|
123
|
+
expect(import.meta.env.DEV).toBe(true)
|
|
124
|
+
|
|
125
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
126
|
+
const dupes = signal([
|
|
127
|
+
{ id: 1, label: 'a' },
|
|
128
|
+
{ id: 1, label: 'b' },
|
|
129
|
+
])
|
|
130
|
+
const { unmount } = mountInBrowser(
|
|
131
|
+
h(
|
|
132
|
+
'div',
|
|
133
|
+
null,
|
|
134
|
+
For({
|
|
135
|
+
each: dupes,
|
|
136
|
+
by: (r: { id: number }) => r.id,
|
|
137
|
+
children: (r: { id: number; label: string }) => h('span', { class: 'dup' }, r.label),
|
|
138
|
+
}),
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
await flush()
|
|
142
|
+
|
|
143
|
+
const calls = warn.mock.calls.flat().join('\n')
|
|
144
|
+
expect(calls).toMatch(/Duplicate key/i)
|
|
145
|
+
unmount()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('hydrateRoot — attaches reactive listeners to existing SSR markup without rerender', async () => {
|
|
149
|
+
// Simulate SSR-rendered HTML in the container.
|
|
150
|
+
const container = document.createElement('div')
|
|
151
|
+
container.innerHTML = '<button id="ssr-btn" type="button">clicks: 0</button>'
|
|
152
|
+
document.body.appendChild(container)
|
|
153
|
+
|
|
154
|
+
const ssrButtonRef = container.querySelector<HTMLButtonElement>('#ssr-btn')!
|
|
155
|
+
const count = signal(0)
|
|
156
|
+
const cleanup = hydrateRoot(
|
|
157
|
+
container,
|
|
158
|
+
h(
|
|
159
|
+
'button',
|
|
160
|
+
{
|
|
161
|
+
id: 'ssr-btn',
|
|
162
|
+
type: 'button',
|
|
163
|
+
onClick: () => count.set(count() + 1),
|
|
164
|
+
},
|
|
165
|
+
() => `clicks: ${count()}`,
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
// Same DOM node — hydrate adopts it, doesn't replace.
|
|
170
|
+
expect(container.querySelector('#ssr-btn')).toBe(ssrButtonRef)
|
|
171
|
+
|
|
172
|
+
// Click triggers the hydrated handler + reactive text update.
|
|
173
|
+
ssrButtonRef.click()
|
|
174
|
+
await flush()
|
|
175
|
+
expect(ssrButtonRef.textContent).toBe('clicks: 1')
|
|
176
|
+
|
|
177
|
+
cleanup()
|
|
178
|
+
container.remove()
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('Portal — children render in a different DOM subtree (not the wrapper)', async () => {
|
|
182
|
+
const target = document.createElement('div')
|
|
183
|
+
target.id = 'portal-target'
|
|
184
|
+
document.body.appendChild(target)
|
|
185
|
+
|
|
186
|
+
const { container, unmount } = mountInBrowser(
|
|
187
|
+
h(
|
|
188
|
+
'div',
|
|
189
|
+
{ id: 'src' },
|
|
190
|
+
h(Portal, { target }, h('span', { id: 'teleported' }, 'over there')),
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
// Portal child is in target, NOT in container.
|
|
195
|
+
expect(container.querySelector('#teleported')).toBeNull()
|
|
196
|
+
expect(target.querySelector('#teleported')?.textContent).toBe('over there')
|
|
197
|
+
unmount()
|
|
198
|
+
target.remove()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('Transition — show=false applies leave classes; transitionend removes element', async () => {
|
|
202
|
+
const visible = signal(true)
|
|
203
|
+
const { container, unmount } = mountInBrowser(
|
|
204
|
+
h(
|
|
205
|
+
Transition,
|
|
206
|
+
{ name: 'fade', show: () => visible() },
|
|
207
|
+
// Real CSS transition so transitionend actually fires when the
|
|
208
|
+
// class swap changes opacity (not just instantly).
|
|
209
|
+
h('div', { id: 'fading', style: 'transition: opacity 30ms; opacity: 1' }, 'hello'),
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
await flush()
|
|
213
|
+
expect(container.querySelector('#fading')).not.toBeNull()
|
|
214
|
+
|
|
215
|
+
visible.set(false)
|
|
216
|
+
// After two rAFs the leave-active + leave-to classes are applied.
|
|
217
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()))
|
|
218
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()))
|
|
219
|
+
|
|
220
|
+
const stillRendered = container.querySelector('#fading')
|
|
221
|
+
if (stillRendered) {
|
|
222
|
+
// Expect at least one of the fade-leave classes during the
|
|
223
|
+
// active phase.
|
|
224
|
+
expect(stillRendered.className).toMatch(/fade-leave/)
|
|
225
|
+
// Manually fire transitionend to short-circuit the 5s safety
|
|
226
|
+
// timeout (we don't care about real timing here, only that the
|
|
227
|
+
// event-driven cleanup path works).
|
|
228
|
+
stillRendered.dispatchEvent(new Event('transitionend', { bubbles: true }))
|
|
229
|
+
}
|
|
230
|
+
await flush()
|
|
231
|
+
await new Promise<void>((r) => setTimeout(r, 16))
|
|
232
|
+
expect(container.querySelector('#fading')).toBeNull()
|
|
233
|
+
unmount()
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('two mount() roots stay isolated — events on one do not affect the other', async () => {
|
|
237
|
+
const c1 = signal(0)
|
|
238
|
+
const c2 = signal(0)
|
|
239
|
+
const root1 = document.createElement('div')
|
|
240
|
+
const root2 = document.createElement('div')
|
|
241
|
+
document.body.append(root1, root2)
|
|
242
|
+
|
|
243
|
+
const u1 = mount(
|
|
244
|
+
h('button', { id: 'b1', onClick: () => c1.set(c1() + 1) }, () => `c1=${c1()}`),
|
|
245
|
+
root1,
|
|
246
|
+
)
|
|
247
|
+
const u2 = mount(
|
|
248
|
+
h('button', { id: 'b2', onClick: () => c2.set(c2() + 1) }, () => `c2=${c2()}`),
|
|
249
|
+
root2,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
root1.querySelector<HTMLButtonElement>('#b1')!.click()
|
|
253
|
+
root1.querySelector<HTMLButtonElement>('#b1')!.click()
|
|
254
|
+
root2.querySelector<HTMLButtonElement>('#b2')!.click()
|
|
255
|
+
await flush()
|
|
256
|
+
|
|
257
|
+
expect(c1()).toBe(2)
|
|
258
|
+
expect(c2()).toBe(1)
|
|
259
|
+
|
|
260
|
+
u1()
|
|
261
|
+
u2()
|
|
262
|
+
root1.remove()
|
|
263
|
+
root2.remove()
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('event delegation — multi-word event names like onPointerDown actually fire', async () => {
|
|
267
|
+
// Regression for the bug fixed alongside this PR:
|
|
268
|
+
// `onPointerDown` was being lowercased to `pointerDown` for the
|
|
269
|
+
// DELEGATED_EVENTS lookup, missing the all-lowercase entry, so the
|
|
270
|
+
// handler was attached via addEventListener('pointerDown', ...) which
|
|
271
|
+
// never fires. Same for mousedown, dblclick, touchstart, etc.
|
|
272
|
+
let pointerDownFired = 0
|
|
273
|
+
let dblClickFired = 0
|
|
274
|
+
const { container, unmount } = mountInBrowser(
|
|
275
|
+
h('div', {
|
|
276
|
+
id: 'evt',
|
|
277
|
+
onPointerDown: () => {
|
|
278
|
+
pointerDownFired++
|
|
279
|
+
},
|
|
280
|
+
onDblClick: () => {
|
|
281
|
+
dblClickFired++
|
|
282
|
+
},
|
|
283
|
+
}),
|
|
284
|
+
)
|
|
285
|
+
const target = container.querySelector('#evt')!
|
|
286
|
+
target.dispatchEvent(
|
|
287
|
+
new PointerEvent('pointerdown', { bubbles: true, pointerId: 1 }),
|
|
288
|
+
)
|
|
289
|
+
target.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
|
|
290
|
+
await flush()
|
|
291
|
+
expect(pointerDownFired).toBe(1)
|
|
292
|
+
expect(dblClickFired).toBe(1)
|
|
293
|
+
unmount()
|
|
294
|
+
})
|
|
295
|
+
})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// End-to-end XSS round-trip for the For-key-marker fix (PR #235).
|
|
4
|
+
// The happy-dom/Node tests assert the encoder produces safe-looking
|
|
5
|
+
// output; this one asserts a real Chromium browser parses the encoded
|
|
6
|
+
// SSR output without executing the injected script.
|
|
7
|
+
//
|
|
8
|
+
// runtime-server can't be imported in the browser (Node async_hooks),
|
|
9
|
+
// so we reconstruct SSR-shaped output directly: a pre-fix (vulnerable)
|
|
10
|
+
// string to prove the attack model, and a post-fix (encoded) string
|
|
11
|
+
// to prove the fix neutralizes it.
|
|
12
|
+
|
|
13
|
+
describe('SSR → real-browser round-trip — For-key marker XSS', () => {
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
vi.restoreAllMocks()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('unencoded attacker key in marker would execute script in Chromium (attack model)', async () => {
|
|
19
|
+
const win = window as Window & { __preFixFired?: boolean }
|
|
20
|
+
win.__preFixFired = false
|
|
21
|
+
|
|
22
|
+
// Build what PRE-fix SSR would have emitted: the raw attacker key
|
|
23
|
+
// interpolated directly into the comment.
|
|
24
|
+
const attackKey = `--><script>window.__preFixFired = true</script><!--`
|
|
25
|
+
const preFixHtml =
|
|
26
|
+
`<!--pyreon-for--><!--k:${attackKey}--><li>item</li><!--/pyreon-for-->`
|
|
27
|
+
|
|
28
|
+
const container = document.createElement('div')
|
|
29
|
+
// innerHTML does not execute <script> per HTML5 spec — so use
|
|
30
|
+
// document.write-free manual parsing via DOMParser, then adopt.
|
|
31
|
+
// This mirrors what a streaming renderer would produce.
|
|
32
|
+
const parsed = new DOMParser().parseFromString(
|
|
33
|
+
`<body>${preFixHtml}</body>`,
|
|
34
|
+
'text/html',
|
|
35
|
+
)
|
|
36
|
+
// Move the parsed body's children into our container; re-insert
|
|
37
|
+
// script tags via createElement so they execute in the real page.
|
|
38
|
+
for (const node of Array.from(parsed.body.childNodes)) {
|
|
39
|
+
if (node.nodeType === 1 && (node as Element).tagName === 'SCRIPT') {
|
|
40
|
+
const s = document.createElement('script')
|
|
41
|
+
s.textContent = (node as HTMLScriptElement).textContent ?? ''
|
|
42
|
+
container.appendChild(s)
|
|
43
|
+
} else {
|
|
44
|
+
container.appendChild(node)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
document.body.appendChild(container)
|
|
48
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
49
|
+
|
|
50
|
+
// Attack model proof: the raw form DOES execute when re-inserted.
|
|
51
|
+
expect(win.__preFixFired).toBe(true)
|
|
52
|
+
|
|
53
|
+
container.remove()
|
|
54
|
+
delete win.__preFixFired
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('encoded marker (post-fix) does NOT execute script in Chromium', async () => {
|
|
58
|
+
const win = window as Window & { __postFixFired?: boolean }
|
|
59
|
+
win.__postFixFired = false
|
|
60
|
+
|
|
61
|
+
// Build what POST-fix SSR emits after safeKeyForMarker:
|
|
62
|
+
// encodeURIComponent then all `-` → `%2D`.
|
|
63
|
+
const attackKey = `--><script>window.__postFixFired = true</script><!--`
|
|
64
|
+
const encoded = encodeURIComponent(attackKey).replace(/-/g, '%2D')
|
|
65
|
+
const postFixHtml =
|
|
66
|
+
`<!--pyreon-for--><!--k:${encoded}--><li>item</li><!--/pyreon-for-->`
|
|
67
|
+
|
|
68
|
+
const container = document.createElement('div')
|
|
69
|
+
const parsed = new DOMParser().parseFromString(
|
|
70
|
+
`<body>${postFixHtml}</body>`,
|
|
71
|
+
'text/html',
|
|
72
|
+
)
|
|
73
|
+
for (const node of Array.from(parsed.body.childNodes)) {
|
|
74
|
+
if (node.nodeType === 1 && (node as Element).tagName === 'SCRIPT') {
|
|
75
|
+
const s = document.createElement('script')
|
|
76
|
+
s.textContent = (node as HTMLScriptElement).textContent ?? ''
|
|
77
|
+
container.appendChild(s)
|
|
78
|
+
} else {
|
|
79
|
+
container.appendChild(node)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
document.body.appendChild(container)
|
|
83
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
84
|
+
|
|
85
|
+
// Post-fix: no script was parsed out of the marker. Encoded key
|
|
86
|
+
// stays inside the comment; nothing to execute.
|
|
87
|
+
expect(win.__postFixFired).toBe(false)
|
|
88
|
+
expect(container.querySelector('script')).toBeNull()
|
|
89
|
+
|
|
90
|
+
container.remove()
|
|
91
|
+
delete win.__postFixFired
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { h } from '@pyreon/core'
|
|
2
|
+
import { signal } from '@pyreon/reactivity'
|
|
3
|
+
import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
// Real-Chromium smoke for the #233 style-key-removal fix. happy-dom can
|
|
7
|
+
// pass because its CSSStyleDeclaration stub is forgiving — this suite
|
|
8
|
+
// asserts the fix holds up against a real engine's styles bag.
|
|
9
|
+
|
|
10
|
+
describe('reactive style — stale keys removed (real browser)', () => {
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
vi.restoreAllMocks()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('drops a key that disappears from a reactive style object', async () => {
|
|
16
|
+
const style = signal<Record<string, string>>({ color: 'rgb(255, 0, 0)', fontSize: '14px' })
|
|
17
|
+
const { container, unmount } = mountInBrowser(
|
|
18
|
+
h('div', { id: 's1', style: () => style() }, 'x'),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
const el = container.querySelector<HTMLDivElement>('#s1')!
|
|
22
|
+
expect(el.style.color).toBe('rgb(255, 0, 0)')
|
|
23
|
+
expect(el.style.fontSize).toBe('14px')
|
|
24
|
+
|
|
25
|
+
style.set({ color: 'rgb(255, 0, 0)' })
|
|
26
|
+
await flush()
|
|
27
|
+
|
|
28
|
+
expect(el.style.color).toBe('rgb(255, 0, 0)')
|
|
29
|
+
// Chromium reports the removed longhand as an empty string on the
|
|
30
|
+
// inline style. This is exactly what #233 intended and what happy-dom
|
|
31
|
+
// only started reporting after the fix landed.
|
|
32
|
+
expect(el.style.fontSize).toBe('')
|
|
33
|
+
unmount()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('clears every tracked key when style becomes null', async () => {
|
|
37
|
+
const style = signal<Record<string, string> | null>({
|
|
38
|
+
color: 'rgb(0, 0, 255)',
|
|
39
|
+
padding: '10px',
|
|
40
|
+
})
|
|
41
|
+
const { container, unmount } = mountInBrowser(
|
|
42
|
+
h('div', { id: 's2', style: () => style() }, 'x'),
|
|
43
|
+
)
|
|
44
|
+
const el = container.querySelector<HTMLDivElement>('#s2')!
|
|
45
|
+
expect(el.style.color).toBe('rgb(0, 0, 255)')
|
|
46
|
+
|
|
47
|
+
style.set(null)
|
|
48
|
+
await flush()
|
|
49
|
+
|
|
50
|
+
expect(el.style.color).toBe('')
|
|
51
|
+
expect(el.style.padding).toBe('')
|
|
52
|
+
unmount()
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { h } from '@pyreon/core'
|
|
2
|
+
import { signal } from '@pyreon/reactivity'
|
|
3
|
+
import { mount } from '../index'
|
|
4
|
+
|
|
5
|
+
describe('reactive style object — stale keys are removed', () => {
|
|
6
|
+
let container: HTMLDivElement
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
container = document.createElement('div')
|
|
10
|
+
document.body.appendChild(container)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
container.remove()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('removes a property that disappears from a reactive style object', () => {
|
|
18
|
+
const style = signal<Record<string, string>>({ color: 'red', fontSize: '14px' })
|
|
19
|
+
|
|
20
|
+
mount(h('div', { style: () => style() }), container)
|
|
21
|
+
|
|
22
|
+
const el = container.querySelector('div') as HTMLDivElement
|
|
23
|
+
expect(el.style.color).toBe('red')
|
|
24
|
+
expect(el.style.fontSize).toBe('14px')
|
|
25
|
+
|
|
26
|
+
// Drop fontSize — previous behavior left it on the element.
|
|
27
|
+
style.set({ color: 'red' })
|
|
28
|
+
|
|
29
|
+
expect(el.style.color).toBe('red')
|
|
30
|
+
expect(el.style.fontSize).toBe('')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('clears all object-mode keys when reactive style becomes null', () => {
|
|
34
|
+
const style = signal<Record<string, string> | null>({ color: 'blue', padding: '10px' })
|
|
35
|
+
|
|
36
|
+
mount(h('div', { style: () => style() }), container)
|
|
37
|
+
|
|
38
|
+
const el = container.querySelector('div') as HTMLDivElement
|
|
39
|
+
expect(el.style.color).toBe('blue')
|
|
40
|
+
expect(el.style.padding).toBe('10px')
|
|
41
|
+
|
|
42
|
+
style.set(null)
|
|
43
|
+
|
|
44
|
+
expect(el.style.color).toBe('')
|
|
45
|
+
expect(el.style.padding).toBe('')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('clears every tracked key when reactive style shrinks to an empty object', () => {
|
|
49
|
+
// Empty-object edge flagged on PR #233: if the new value is `{}`, the
|
|
50
|
+
// `value == null` branch in applyStyleProp is skipped, but the key-diff
|
|
51
|
+
// loop still iterates the prior tracked set and removes each one.
|
|
52
|
+
const style = signal<Record<string, string>>({ color: 'green', margin: '2px' })
|
|
53
|
+
|
|
54
|
+
mount(h('div', { style: () => style() }), container)
|
|
55
|
+
|
|
56
|
+
const el = container.querySelector('div') as HTMLDivElement
|
|
57
|
+
expect(el.style.color).toBe('green')
|
|
58
|
+
expect(el.style.margin).toBe('2px')
|
|
59
|
+
|
|
60
|
+
style.set({})
|
|
61
|
+
|
|
62
|
+
expect(el.style.color).toBe('')
|
|
63
|
+
expect(el.style.margin).toBe('')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('handles object → string → object transitions without leaking keys', () => {
|
|
67
|
+
const style = signal<string | Record<string, string>>({ color: 'red' })
|
|
68
|
+
|
|
69
|
+
mount(h('div', { style: () => style() }), container)
|
|
70
|
+
|
|
71
|
+
const el = container.querySelector('div') as HTMLDivElement
|
|
72
|
+
expect(el.style.color).toBe('red')
|
|
73
|
+
|
|
74
|
+
// Swap to string form — cssText replaces everything.
|
|
75
|
+
style.set('background: yellow;')
|
|
76
|
+
expect(el.style.color).toBe('')
|
|
77
|
+
expect(el.style.background).toContain('yellow')
|
|
78
|
+
|
|
79
|
+
// Swap back to object form. The previous object-mode tracking was
|
|
80
|
+
// reset by the string assignment, so we start fresh and must not
|
|
81
|
+
// carry forward stale keys from the initial object.
|
|
82
|
+
style.set({ margin: '5px' })
|
|
83
|
+
expect(el.style.margin).toBe('5px')
|
|
84
|
+
// Previously-set `background` from the string form stays put because
|
|
85
|
+
// cssText can't round-trip through key-level tracking — this is
|
|
86
|
+
// identical to React/Solid/Vue behavior; document it.
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { ComponentFn } from '@pyreon/core'
|
|
2
|
+
import { h } from '@pyreon/core'
|
|
3
|
+
import { signal } from '@pyreon/reactivity'
|
|
4
|
+
import { Transition as _Transition, mount } from '../index'
|
|
5
|
+
|
|
6
|
+
const Transition = _Transition as unknown as ComponentFn<Record<string, unknown>>
|
|
7
|
+
|
|
8
|
+
function container(): HTMLElement {
|
|
9
|
+
const el = document.createElement('div')
|
|
10
|
+
document.body.appendChild(el)
|
|
11
|
+
return el
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Regression for the 5s safety-timer leak in applyEnter / applyLeave.
|
|
15
|
+
// Before the fix, when transitionend fired normally the 5s setTimeout was
|
|
16
|
+
// never cleared and would re-invoke `done()` later, firing onAfterEnter /
|
|
17
|
+
// onAfterLeave twice and leaking closures over the element.
|
|
18
|
+
describe('Transition — safety-timer leak (regression)', () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
vi.useFakeTimers()
|
|
21
|
+
})
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.useRealTimers()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('onAfterEnter fires exactly once when transitionend fires before 5s safety timeout', async () => {
|
|
27
|
+
const el = container()
|
|
28
|
+
const show = signal(false)
|
|
29
|
+
const onAfterEnter = vi.fn()
|
|
30
|
+
|
|
31
|
+
mount(
|
|
32
|
+
h(
|
|
33
|
+
Transition,
|
|
34
|
+
{ name: 'fade', show: () => show(), onAfterEnter },
|
|
35
|
+
h('div', { class: 'enter-leak' }, 'x'),
|
|
36
|
+
),
|
|
37
|
+
el,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
show.set(true)
|
|
41
|
+
await vi.advanceTimersByTimeAsync(20)
|
|
42
|
+
|
|
43
|
+
const target = el.querySelector('.enter-leak') as HTMLElement
|
|
44
|
+
expect(target).not.toBeNull()
|
|
45
|
+
|
|
46
|
+
target.dispatchEvent(new Event('transitionend'))
|
|
47
|
+
expect(onAfterEnter).toHaveBeenCalledTimes(1)
|
|
48
|
+
|
|
49
|
+
// Advance past the safety timeout. With the leak, done() would fire again.
|
|
50
|
+
await vi.advanceTimersByTimeAsync(6000)
|
|
51
|
+
expect(onAfterEnter).toHaveBeenCalledTimes(1)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('onAfterLeave fires exactly once when transitionend fires before 5s safety timeout', async () => {
|
|
55
|
+
const el = container()
|
|
56
|
+
const show = signal(true)
|
|
57
|
+
const onAfterLeave = vi.fn()
|
|
58
|
+
|
|
59
|
+
mount(
|
|
60
|
+
h(
|
|
61
|
+
Transition,
|
|
62
|
+
{ name: 'fade', show: () => show(), onAfterLeave },
|
|
63
|
+
h('div', { class: 'leave-leak' }, 'x'),
|
|
64
|
+
),
|
|
65
|
+
el,
|
|
66
|
+
)
|
|
67
|
+
await vi.advanceTimersByTimeAsync(20)
|
|
68
|
+
|
|
69
|
+
show.set(false)
|
|
70
|
+
await vi.advanceTimersByTimeAsync(20)
|
|
71
|
+
|
|
72
|
+
const target = el.querySelector('.leave-leak') as HTMLElement
|
|
73
|
+
expect(target).not.toBeNull()
|
|
74
|
+
|
|
75
|
+
target.dispatchEvent(new Event('transitionend'))
|
|
76
|
+
expect(onAfterLeave).toHaveBeenCalledTimes(1)
|
|
77
|
+
|
|
78
|
+
await vi.advanceTimersByTimeAsync(6000)
|
|
79
|
+
expect(onAfterLeave).toHaveBeenCalledTimes(1)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// Regression: component unmount during an in-flight transition used to
|
|
83
|
+
// leave the 5s safety timer running. onAfterEnter / onAfterLeave would
|
|
84
|
+
// fire on a detached element up to 5 seconds after unmount.
|
|
85
|
+
test('onAfterEnter does NOT fire after component unmount during enter transition', async () => {
|
|
86
|
+
const el = container()
|
|
87
|
+
const show = signal(false)
|
|
88
|
+
const onAfterEnter = vi.fn()
|
|
89
|
+
const dispose = mount(
|
|
90
|
+
h(
|
|
91
|
+
Transition,
|
|
92
|
+
{ name: 'fade', show: () => show(), onAfterEnter },
|
|
93
|
+
h('div', { class: 'unmount-enter' }, 'x'),
|
|
94
|
+
),
|
|
95
|
+
el,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
show.set(true)
|
|
99
|
+
await vi.advanceTimersByTimeAsync(20)
|
|
100
|
+
// Mid-transition — unmount without firing transitionend.
|
|
101
|
+
dispose()
|
|
102
|
+
await vi.advanceTimersByTimeAsync(6000)
|
|
103
|
+
expect(onAfterEnter).not.toHaveBeenCalled()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('onAfterLeave does NOT fire after component unmount during leave transition', async () => {
|
|
107
|
+
const el = container()
|
|
108
|
+
const show = signal(true)
|
|
109
|
+
const onAfterLeave = vi.fn()
|
|
110
|
+
const dispose = mount(
|
|
111
|
+
h(
|
|
112
|
+
Transition,
|
|
113
|
+
{ name: 'fade', show: () => show(), onAfterLeave },
|
|
114
|
+
h('div', { class: 'unmount-leave' }, 'x'),
|
|
115
|
+
),
|
|
116
|
+
el,
|
|
117
|
+
)
|
|
118
|
+
await vi.advanceTimersByTimeAsync(20)
|
|
119
|
+
|
|
120
|
+
show.set(false)
|
|
121
|
+
await vi.advanceTimersByTimeAsync(20)
|
|
122
|
+
dispose()
|
|
123
|
+
await vi.advanceTimersByTimeAsync(6000)
|
|
124
|
+
expect(onAfterLeave).not.toHaveBeenCalled()
|
|
125
|
+
})
|
|
126
|
+
})
|