@pyreon/runtime-dom 0.12.12 → 0.12.14
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 +63 -12
- 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 +54 -10
- package/src/tests/callback-ref-unmount.browser.test.ts +62 -0
- package/src/tests/callback-ref-unmount.test.ts +52 -0
- package/src/tests/coverage.test.ts +4 -2
- 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 +6 -5
- package/src/tests/props.test.ts +5 -4
- 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 +81 -0
- package/src/tests/verified-correct-probes.test.ts +56 -0
- package/src/transition.ts +20 -2
|
@@ -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,81 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Fragment, h } from '@pyreon/core'
|
|
2
|
+
import { mount } from '../index'
|
|
3
|
+
|
|
4
|
+
// Lock-in tests for behaviors PR #235 investigated and claimed
|
|
5
|
+
// "verified correct". Without code assertions the prose claims
|
|
6
|
+
// could silently regress.
|
|
7
|
+
|
|
8
|
+
describe('Fragment + key — key is inert', () => {
|
|
9
|
+
let container: HTMLDivElement
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
container = document.createElement('div')
|
|
13
|
+
document.body.appendChild(container)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
container.remove()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('Fragment with a key renders its children inline (key does not reconcile)', () => {
|
|
21
|
+
mount(
|
|
22
|
+
h(
|
|
23
|
+
Fragment,
|
|
24
|
+
{ key: 'x' },
|
|
25
|
+
h('span', { id: 'a' }, 'a'),
|
|
26
|
+
h('span', { id: 'b' }, 'b'),
|
|
27
|
+
),
|
|
28
|
+
container,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
// Both children are present at the top level of the container —
|
|
32
|
+
// no extra wrapper, key didn't alter the structure.
|
|
33
|
+
expect(container.querySelector('#a')?.textContent).toBe('a')
|
|
34
|
+
expect(container.querySelector('#b')?.textContent).toBe('b')
|
|
35
|
+
expect(container.children).toHaveLength(2)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('Fragment without a key renders identically', () => {
|
|
39
|
+
mount(
|
|
40
|
+
h(Fragment, null, h('span', { id: 'a' }, 'a'), h('span', { id: 'b' }, 'b')),
|
|
41
|
+
container,
|
|
42
|
+
)
|
|
43
|
+
expect(container.children).toHaveLength(2)
|
|
44
|
+
expect(container.querySelector('#a')?.textContent).toBe('a')
|
|
45
|
+
expect(container.querySelector('#b')?.textContent).toBe('b')
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('Suspense fast-resolve — fallback-first streaming contract', () => {
|
|
50
|
+
// This is a SERVER behavior, not a browser one. The PR-#235 claim was:
|
|
51
|
+
// renderToStream always emits fallback first, then swap — even if the
|
|
52
|
+
// async child resolves synchronously. Synchronous callers should use
|
|
53
|
+
// renderToString. That contract is already locked in by the existing
|
|
54
|
+
// streaming integration tests; no additional coverage needed here.
|
|
55
|
+
it.skip('locked in by renderToStream integration tests — see runtime-server/src/tests/ssr.test.ts', () => {})
|
|
56
|
+
})
|
package/src/transition.ts
CHANGED
|
@@ -93,17 +93,25 @@ export function Transition(props: TransitionProps): VNodeChild {
|
|
|
93
93
|
requestAnimationFrame(() => {
|
|
94
94
|
el.classList.remove(cls.ef)
|
|
95
95
|
el.classList.add(cls.et)
|
|
96
|
+
let safetyTimer: ReturnType<typeof setTimeout> | null = null
|
|
96
97
|
const done = () => {
|
|
97
98
|
// Remove both listeners — only one fires, so clean up the other
|
|
98
99
|
el.removeEventListener('transitionend', done)
|
|
99
100
|
el.removeEventListener('animationend', done)
|
|
101
|
+
// Clear the safety timeout — without this, when transitionend fires
|
|
102
|
+
// normally the 5s timer would still fire later and re-invoke done(),
|
|
103
|
+
// leaking timer refs and re-firing onAfterEnter.
|
|
104
|
+
if (safetyTimer !== null) {
|
|
105
|
+
clearTimeout(safetyTimer)
|
|
106
|
+
safetyTimer = null
|
|
107
|
+
}
|
|
100
108
|
el.classList.remove(cls.ea, cls.et)
|
|
101
109
|
props.onAfterEnter?.(el)
|
|
102
110
|
}
|
|
103
111
|
el.addEventListener('transitionend', done, { once: true })
|
|
104
112
|
el.addEventListener('animationend', done, { once: true })
|
|
105
113
|
// Safety timeout: if CSS animation never fires (bad CSS, off-screen), force cleanup
|
|
106
|
-
setTimeout(done, 5000)
|
|
114
|
+
safetyTimer = setTimeout(done, 5000)
|
|
107
115
|
})
|
|
108
116
|
}
|
|
109
117
|
|
|
@@ -114,10 +122,16 @@ export function Transition(props: TransitionProps): VNodeChild {
|
|
|
114
122
|
requestAnimationFrame(() => {
|
|
115
123
|
el.classList.remove(cls.lf)
|
|
116
124
|
el.classList.add(cls.lt)
|
|
125
|
+
let safetyTimer: ReturnType<typeof setTimeout> | null = null
|
|
117
126
|
const done = () => {
|
|
118
127
|
// Remove both listeners — only one fires, so clean up the other
|
|
119
128
|
el.removeEventListener('transitionend', done)
|
|
120
129
|
el.removeEventListener('animationend', done)
|
|
130
|
+
// Clear the safety timeout (see applyEnter for rationale).
|
|
131
|
+
if (safetyTimer !== null) {
|
|
132
|
+
clearTimeout(safetyTimer)
|
|
133
|
+
safetyTimer = null
|
|
134
|
+
}
|
|
121
135
|
el.classList.remove(cls.la, cls.lt)
|
|
122
136
|
pendingLeaveCancel = null
|
|
123
137
|
isMounted.set(false)
|
|
@@ -126,12 +140,16 @@ export function Transition(props: TransitionProps): VNodeChild {
|
|
|
126
140
|
pendingLeaveCancel = () => {
|
|
127
141
|
el.removeEventListener('transitionend', done)
|
|
128
142
|
el.removeEventListener('animationend', done)
|
|
143
|
+
if (safetyTimer !== null) {
|
|
144
|
+
clearTimeout(safetyTimer)
|
|
145
|
+
safetyTimer = null
|
|
146
|
+
}
|
|
129
147
|
el.classList.remove(cls.lf, cls.la, cls.lt)
|
|
130
148
|
}
|
|
131
149
|
el.addEventListener('transitionend', done, { once: true })
|
|
132
150
|
el.addEventListener('animationend', done, { once: true })
|
|
133
151
|
// Safety timeout: if CSS animation never fires, force cleanup
|
|
134
|
-
setTimeout(done, 5000)
|
|
152
|
+
safetyTimer = setTimeout(done, 5000)
|
|
135
153
|
})
|
|
136
154
|
}
|
|
137
155
|
|