@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.
@@ -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
+ })