@pyreon/runtime-dom 0.12.14 → 0.13.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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +105 -16
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +7 -6
- package/src/manifest.ts +218 -0
- package/src/props.ts +58 -19
- package/src/tests/manifest-snapshot.test.ts +84 -0
- package/src/tests/mount.test.ts +89 -0
- package/src/tests/props.test.ts +117 -0
- package/src/tests/transition-timeout-leak.test.ts +45 -0
- package/src/transition-group.ts +80 -8
- package/src/transition.ts +26 -1
package/src/tests/props.test.ts
CHANGED
|
@@ -269,6 +269,123 @@ describe('applyProp — innerHTML', () => {
|
|
|
269
269
|
expect(warnSpy).not.toHaveBeenCalled()
|
|
270
270
|
warnSpy.mockRestore()
|
|
271
271
|
})
|
|
272
|
+
|
|
273
|
+
test('reactive innerHTML accessor — function value is called, not stringified', async () => {
|
|
274
|
+
// Regression: the JSX compiler emits `innerHTML={getIcon(props.x ? "a" : "b")}`
|
|
275
|
+
// as a `() => …` accessor. Without function-value handling here, the
|
|
276
|
+
// closure was set as literal text — `() => getIcon(...)` rendered
|
|
277
|
+
// verbatim instead of the SVG.
|
|
278
|
+
const { signal } = await import('@pyreon/reactivity')
|
|
279
|
+
const el = document.createElement('div')
|
|
280
|
+
const which = signal<'a' | 'b'>('a')
|
|
281
|
+
const cleanup = applyProp(el, 'innerHTML', () => `<span data-x="${which()}">x</span>`)
|
|
282
|
+
expect(el.querySelector('[data-x="a"]')).not.toBeNull()
|
|
283
|
+
expect(el.innerHTML).not.toContain('=>')
|
|
284
|
+
which.set('b')
|
|
285
|
+
expect(el.querySelector('[data-x="b"]')).not.toBeNull()
|
|
286
|
+
cleanup?.()
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test('reactive dangerouslySetInnerHTML accessor — function value is called, not stringified', async () => {
|
|
290
|
+
const { signal } = await import('@pyreon/reactivity')
|
|
291
|
+
const el = document.createElement('div')
|
|
292
|
+
const html = signal('<em>one</em>')
|
|
293
|
+
const cleanup = applyProp(el, 'dangerouslySetInnerHTML', () => ({ __html: html() }))
|
|
294
|
+
expect(el.innerHTML).toBe('<em>one</em>')
|
|
295
|
+
html.set('<em>two</em>')
|
|
296
|
+
expect(el.innerHTML).toBe('<em>two</em>')
|
|
297
|
+
cleanup?.()
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
test('dev warning fires if a function reaches applyStaticProp directly (defensive guard)', () => {
|
|
301
|
+
// applyStaticProp is internal — reachable only if a future special-case
|
|
302
|
+
// branch in applyProp bypasses the reactive-wrap dance. The dev guard
|
|
303
|
+
// catches that regression at first render.
|
|
304
|
+
const el = document.createElement('div')
|
|
305
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
306
|
+
// Indirect: trigger by routing a function through `applyProp` for a
|
|
307
|
+
// key that DOESN'T have a special case — exercises the reactive path,
|
|
308
|
+
// which calls the accessor + passes the result. The accessor itself
|
|
309
|
+
// returning a function would surface the warning.
|
|
310
|
+
applyProp(el, 'innerHTML', () => () => '<em>nested</em>')
|
|
311
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
312
|
+
expect.stringContaining('applyStaticProp received a function for "innerHTML"'),
|
|
313
|
+
)
|
|
314
|
+
warnSpy.mockRestore()
|
|
315
|
+
})
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
// Comprehensive sweep: every string-typed sink must handle reactive
|
|
319
|
+
// (function) values. The original bug was specific to innerHTML, but the
|
|
320
|
+
// structural fix should cover ALL sinks the same way. These tests assert
|
|
321
|
+
// that.
|
|
322
|
+
describe('applyProp — reactive function values across all sink kinds', () => {
|
|
323
|
+
test('reactive href accessor on <a>', async () => {
|
|
324
|
+
const { signal } = await import('@pyreon/reactivity')
|
|
325
|
+
const el = document.createElement('a')
|
|
326
|
+
const path = signal('/one')
|
|
327
|
+
const cleanup = applyProp(el, 'href', () => path())
|
|
328
|
+
expect(el.getAttribute('href')).toBe('/one')
|
|
329
|
+
path.set('/two')
|
|
330
|
+
expect(el.getAttribute('href')).toBe('/two')
|
|
331
|
+
cleanup?.()
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
test('reactive src accessor on <img>', async () => {
|
|
335
|
+
const { signal } = await import('@pyreon/reactivity')
|
|
336
|
+
const el = document.createElement('img')
|
|
337
|
+
const url = signal('/a.png')
|
|
338
|
+
const cleanup = applyProp(el, 'src', () => url())
|
|
339
|
+
// <img> exposes `src` as a normalized absolute URL — assert via getAttribute
|
|
340
|
+
expect(el.getAttribute('src')).toBe('/a.png')
|
|
341
|
+
url.set('/b.png')
|
|
342
|
+
expect(el.getAttribute('src')).toBe('/b.png')
|
|
343
|
+
cleanup?.()
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
test('reactive value accessor on <input>', async () => {
|
|
347
|
+
const { signal } = await import('@pyreon/reactivity')
|
|
348
|
+
const el = document.createElement('input')
|
|
349
|
+
const val = signal('alpha')
|
|
350
|
+
const cleanup = applyProp(el, 'value', () => val())
|
|
351
|
+
expect((el as HTMLInputElement).value).toBe('alpha')
|
|
352
|
+
val.set('beta')
|
|
353
|
+
expect((el as HTMLInputElement).value).toBe('beta')
|
|
354
|
+
cleanup?.()
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
test('reactive title accessor (data attribute pattern)', async () => {
|
|
358
|
+
const { signal } = await import('@pyreon/reactivity')
|
|
359
|
+
const el = document.createElement('div')
|
|
360
|
+
const tip = signal('hello')
|
|
361
|
+
const cleanup = applyProp(el, 'title', () => tip())
|
|
362
|
+
expect(el.getAttribute('title')).toBe('hello')
|
|
363
|
+
tip.set('world')
|
|
364
|
+
expect(el.getAttribute('title')).toBe('world')
|
|
365
|
+
cleanup?.()
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
test('reactive class accessor (string form)', async () => {
|
|
369
|
+
const { signal } = await import('@pyreon/reactivity')
|
|
370
|
+
const el = document.createElement('div')
|
|
371
|
+
const cls = signal('one')
|
|
372
|
+
const cleanup = applyProp(el, 'class', () => cls())
|
|
373
|
+
expect(el.className).toBe('one')
|
|
374
|
+
cls.set('two')
|
|
375
|
+
expect(el.className).toBe('two')
|
|
376
|
+
cleanup?.()
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
test('reactive style accessor (object form)', async () => {
|
|
380
|
+
const { signal } = await import('@pyreon/reactivity')
|
|
381
|
+
const el = document.createElement('div')
|
|
382
|
+
const color = signal('red')
|
|
383
|
+
const cleanup = applyProp(el, 'style', () => ({ color: color() }))
|
|
384
|
+
expect(el.style.color).toBe('red')
|
|
385
|
+
color.set('blue')
|
|
386
|
+
expect(el.style.color).toBe('blue')
|
|
387
|
+
cleanup?.()
|
|
388
|
+
})
|
|
272
389
|
})
|
|
273
390
|
|
|
274
391
|
// ─── applyProp — URL safety ──────────────────────────────────────────────────
|
|
@@ -78,4 +78,49 @@ describe('Transition — safety-timer leak (regression)', () => {
|
|
|
78
78
|
await vi.advanceTimersByTimeAsync(6000)
|
|
79
79
|
expect(onAfterLeave).toHaveBeenCalledTimes(1)
|
|
80
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
|
+
})
|
|
81
126
|
})
|
package/src/transition-group.ts
CHANGED
|
@@ -41,6 +41,15 @@ type ItemEntry = {
|
|
|
41
41
|
ref: ReturnType<typeof createRef<HTMLElement>>
|
|
42
42
|
cleanup: () => void
|
|
43
43
|
leaving: boolean
|
|
44
|
+
/**
|
|
45
|
+
* Cancel function for an in-progress enter / leave / move transition —
|
|
46
|
+
* removes listeners, clears the safety timer, strips active-state
|
|
47
|
+
* classes, but does NOT fire the onAfterX callback. Called when a
|
|
48
|
+
* transition is superseded or when the whole TransitionGroup unmounts
|
|
49
|
+
* mid-transition (so onAfterEnter/Leave doesn't fire on a detached
|
|
50
|
+
* element and the 5s timer doesn't leak past unmount).
|
|
51
|
+
*/
|
|
52
|
+
cancelTransition: (() => void) | null
|
|
44
53
|
}
|
|
45
54
|
|
|
46
55
|
/**
|
|
@@ -87,40 +96,80 @@ export function TransitionGroup<T = unknown>(props: TransitionGroupProps<T>): VN
|
|
|
87
96
|
const ready = signal(false)
|
|
88
97
|
let firstRun = true
|
|
89
98
|
|
|
90
|
-
const applyEnter = (el: HTMLElement) => {
|
|
99
|
+
const applyEnter = (entry: ItemEntry, el: HTMLElement) => {
|
|
91
100
|
props.onBeforeEnter?.(el)
|
|
92
101
|
el.classList.remove(cls.lf, cls.la, cls.lt)
|
|
93
102
|
el.classList.add(cls.ef, cls.ea)
|
|
94
103
|
requestAnimationFrame(() => {
|
|
95
104
|
el.classList.remove(cls.ef)
|
|
96
105
|
el.classList.add(cls.et)
|
|
106
|
+
let safetyTimer: ReturnType<typeof setTimeout> | null = null
|
|
97
107
|
const done = () => {
|
|
98
108
|
el.removeEventListener('transitionend', done)
|
|
99
109
|
el.removeEventListener('animationend', done)
|
|
110
|
+
if (safetyTimer !== null) {
|
|
111
|
+
clearTimeout(safetyTimer)
|
|
112
|
+
safetyTimer = null
|
|
113
|
+
}
|
|
114
|
+
entry.cancelTransition = null
|
|
100
115
|
el.classList.remove(cls.ea, cls.et)
|
|
101
116
|
props.onAfterEnter?.(el)
|
|
102
117
|
}
|
|
118
|
+
entry.cancelTransition = () => {
|
|
119
|
+
el.removeEventListener('transitionend', done)
|
|
120
|
+
el.removeEventListener('animationend', done)
|
|
121
|
+
if (safetyTimer !== null) {
|
|
122
|
+
clearTimeout(safetyTimer)
|
|
123
|
+
safetyTimer = null
|
|
124
|
+
}
|
|
125
|
+
el.classList.remove(cls.ef, cls.ea, cls.et)
|
|
126
|
+
}
|
|
103
127
|
el.addEventListener('transitionend', done, { once: true })
|
|
104
128
|
el.addEventListener('animationend', done, { once: true })
|
|
129
|
+
// Safety timeout: if CSS animation never fires (off-screen, zero
|
|
130
|
+
// duration, `display: none`), force cleanup so the entry's
|
|
131
|
+
// onAfterEnter runs and the listener + closure don't leak.
|
|
132
|
+
safetyTimer = setTimeout(done, 5000)
|
|
105
133
|
})
|
|
106
134
|
}
|
|
107
135
|
|
|
108
|
-
const applyLeave = (el: HTMLElement, onDone: () => void) => {
|
|
136
|
+
const applyLeave = (entry: ItemEntry, el: HTMLElement, onDone: () => void) => {
|
|
109
137
|
props.onBeforeLeave?.(el)
|
|
110
138
|
el.classList.remove(cls.ef, cls.ea, cls.et)
|
|
111
139
|
el.classList.add(cls.lf, cls.la)
|
|
112
140
|
requestAnimationFrame(() => {
|
|
113
141
|
el.classList.remove(cls.lf)
|
|
114
142
|
el.classList.add(cls.lt)
|
|
143
|
+
let safetyTimer: ReturnType<typeof setTimeout> | null = null
|
|
115
144
|
const done = () => {
|
|
116
145
|
el.removeEventListener('transitionend', done)
|
|
117
146
|
el.removeEventListener('animationend', done)
|
|
147
|
+
if (safetyTimer !== null) {
|
|
148
|
+
clearTimeout(safetyTimer)
|
|
149
|
+
safetyTimer = null
|
|
150
|
+
}
|
|
151
|
+
entry.cancelTransition = null
|
|
118
152
|
el.classList.remove(cls.la, cls.lt)
|
|
119
153
|
props.onAfterLeave?.(el)
|
|
120
154
|
onDone()
|
|
121
155
|
}
|
|
156
|
+
entry.cancelTransition = () => {
|
|
157
|
+
el.removeEventListener('transitionend', done)
|
|
158
|
+
el.removeEventListener('animationend', done)
|
|
159
|
+
if (safetyTimer !== null) {
|
|
160
|
+
clearTimeout(safetyTimer)
|
|
161
|
+
safetyTimer = null
|
|
162
|
+
}
|
|
163
|
+
el.classList.remove(cls.lf, cls.la, cls.lt)
|
|
164
|
+
}
|
|
122
165
|
el.addEventListener('transitionend', done, { once: true })
|
|
123
166
|
el.addEventListener('animationend', done, { once: true })
|
|
167
|
+
// Safety timeout: CRITICAL for transition-group. Without it, a list
|
|
168
|
+
// item whose leave transition never fires (off-screen, zero
|
|
169
|
+
// duration, `display: none`) stays in the `entries` Map forever
|
|
170
|
+
// because `onDone` never runs to `entries.delete(key)` — a real
|
|
171
|
+
// memory leak that grows with every list mutation.
|
|
172
|
+
safetyTimer = setTimeout(done, 5000)
|
|
124
173
|
})
|
|
125
174
|
}
|
|
126
175
|
|
|
@@ -131,7 +180,7 @@ export function TransitionGroup<T = unknown>(props: TransitionGroupProps<T>): VN
|
|
|
131
180
|
entry.leaving = true
|
|
132
181
|
const el = entry.ref.current
|
|
133
182
|
if (el) {
|
|
134
|
-
applyLeave(el, () => {
|
|
183
|
+
applyLeave(entry, el, () => {
|
|
135
184
|
entry.cleanup()
|
|
136
185
|
entries.delete(key)
|
|
137
186
|
})
|
|
@@ -156,25 +205,41 @@ export function TransitionGroup<T = unknown>(props: TransitionGroupProps<T>): VN
|
|
|
156
205
|
? { ...rawVNode, props: { ...rawVNode.props, ref: itemRef } as Props }
|
|
157
206
|
: rawVNode
|
|
158
207
|
const cleanup = mountChild(vnode, container, null)
|
|
159
|
-
const entry: ItemEntry = { key, ref: itemRef, cleanup, leaving: false }
|
|
208
|
+
const entry: ItemEntry = { key, ref: itemRef, cleanup, leaving: false, cancelTransition: null }
|
|
160
209
|
entries.set(key, entry)
|
|
161
210
|
newEntries.push(entry)
|
|
162
211
|
}
|
|
163
212
|
return newEntries
|
|
164
213
|
}
|
|
165
214
|
|
|
166
|
-
const startMoveAnimation = (el: HTMLElement) => {
|
|
215
|
+
const startMoveAnimation = (entry: ItemEntry, el: HTMLElement) => {
|
|
167
216
|
requestAnimationFrame(() => {
|
|
168
217
|
el.classList.add(cls.mv)
|
|
169
218
|
el.style.transform = ''
|
|
170
219
|
el.style.transition = ''
|
|
220
|
+
let safetyTimer: ReturnType<typeof setTimeout> | null = null
|
|
171
221
|
const done = () => {
|
|
172
222
|
el.removeEventListener('transitionend', done)
|
|
173
223
|
el.removeEventListener('animationend', done)
|
|
224
|
+
if (safetyTimer !== null) {
|
|
225
|
+
clearTimeout(safetyTimer)
|
|
226
|
+
safetyTimer = null
|
|
227
|
+
}
|
|
228
|
+
entry.cancelTransition = null
|
|
229
|
+
el.classList.remove(cls.mv)
|
|
230
|
+
}
|
|
231
|
+
entry.cancelTransition = () => {
|
|
232
|
+
el.removeEventListener('transitionend', done)
|
|
233
|
+
el.removeEventListener('animationend', done)
|
|
234
|
+
if (safetyTimer !== null) {
|
|
235
|
+
clearTimeout(safetyTimer)
|
|
236
|
+
safetyTimer = null
|
|
237
|
+
}
|
|
174
238
|
el.classList.remove(cls.mv)
|
|
175
239
|
}
|
|
176
240
|
el.addEventListener('transitionend', done, { once: true })
|
|
177
241
|
el.addEventListener('animationend', done, { once: true })
|
|
242
|
+
safetyTimer = setTimeout(done, 5000)
|
|
178
243
|
})
|
|
179
244
|
}
|
|
180
245
|
|
|
@@ -187,7 +252,7 @@ export function TransitionGroup<T = unknown>(props: TransitionGroupProps<T>): VN
|
|
|
187
252
|
const el = entry.ref.current
|
|
188
253
|
el.style.transform = `translate(${dx}px, ${dy}px)`
|
|
189
254
|
el.style.transition = 'none'
|
|
190
|
-
startMoveAnimation(el)
|
|
255
|
+
startMoveAnimation(entry, el)
|
|
191
256
|
}
|
|
192
257
|
|
|
193
258
|
/** Apply FLIP move animations for items that shifted position. */
|
|
@@ -224,7 +289,7 @@ export function TransitionGroup<T = unknown>(props: TransitionGroupProps<T>): VN
|
|
|
224
289
|
const animateNewEntries = (newEntries: ItemEntry[]) => {
|
|
225
290
|
for (const entry of newEntries) {
|
|
226
291
|
queueMicrotask(() => {
|
|
227
|
-
if (entry.ref.current) applyEnter(entry.ref.current)
|
|
292
|
+
if (entry.ref.current) applyEnter(entry, entry.ref.current)
|
|
228
293
|
})
|
|
229
294
|
}
|
|
230
295
|
}
|
|
@@ -255,7 +320,14 @@ export function TransitionGroup<T = unknown>(props: TransitionGroupProps<T>): VN
|
|
|
255
320
|
|
|
256
321
|
onUnmount(() => {
|
|
257
322
|
e.dispose()
|
|
258
|
-
for (const entry of entries.values())
|
|
323
|
+
for (const entry of entries.values()) {
|
|
324
|
+
// Cancel any in-progress enter/leave/move transition so the 5s
|
|
325
|
+
// safety timer doesn't keep running past container unmount and
|
|
326
|
+
// onAfterEnter / onAfterLeave don't fire on a detached element.
|
|
327
|
+
entry.cancelTransition?.()
|
|
328
|
+
entry.cancelTransition = null
|
|
329
|
+
entry.cleanup()
|
|
330
|
+
}
|
|
259
331
|
entries.clear()
|
|
260
332
|
})
|
|
261
333
|
|
package/src/transition.ts
CHANGED
|
@@ -80,13 +80,20 @@ export function Transition(props: TransitionProps): VNodeChild {
|
|
|
80
80
|
const ref = createRef<HTMLElement>()
|
|
81
81
|
const isMounted = signal(runUntracked<boolean>(props.show))
|
|
82
82
|
|
|
83
|
-
// Cancel
|
|
83
|
+
// Cancel in-progress enter / leave when the component unmounts or when a
|
|
84
|
+
// new transition supersedes the current one. Both are set inside their
|
|
85
|
+
// respective applyX(). Calling the cancel removes event listeners, clears
|
|
86
|
+
// the safety timer, and strips active-state classes — WITHOUT firing the
|
|
87
|
+
// onAfterX callback (which would run on a detached element after unmount).
|
|
88
|
+
let pendingEnterCancel: (() => void) | null = null
|
|
84
89
|
let pendingLeaveCancel: (() => void) | null = null
|
|
85
90
|
let initialized = false
|
|
86
91
|
|
|
87
92
|
const applyEnter = (el: HTMLElement) => {
|
|
88
93
|
pendingLeaveCancel?.()
|
|
89
94
|
pendingLeaveCancel = null
|
|
95
|
+
pendingEnterCancel?.()
|
|
96
|
+
pendingEnterCancel = null
|
|
90
97
|
props.onBeforeEnter?.(el)
|
|
91
98
|
el.classList.remove(cls.lf, cls.la, cls.lt)
|
|
92
99
|
el.classList.add(cls.ef, cls.ea)
|
|
@@ -105,9 +112,21 @@ export function Transition(props: TransitionProps): VNodeChild {
|
|
|
105
112
|
clearTimeout(safetyTimer)
|
|
106
113
|
safetyTimer = null
|
|
107
114
|
}
|
|
115
|
+
pendingEnterCancel = null
|
|
108
116
|
el.classList.remove(cls.ea, cls.et)
|
|
109
117
|
props.onAfterEnter?.(el)
|
|
110
118
|
}
|
|
119
|
+
// Cancel path (called from onUnmount or a superseding transition): tears
|
|
120
|
+
// down without firing onAfterEnter on a detached element.
|
|
121
|
+
pendingEnterCancel = () => {
|
|
122
|
+
el.removeEventListener('transitionend', done)
|
|
123
|
+
el.removeEventListener('animationend', done)
|
|
124
|
+
if (safetyTimer !== null) {
|
|
125
|
+
clearTimeout(safetyTimer)
|
|
126
|
+
safetyTimer = null
|
|
127
|
+
}
|
|
128
|
+
el.classList.remove(cls.ef, cls.ea, cls.et)
|
|
129
|
+
}
|
|
111
130
|
el.addEventListener('transitionend', done, { once: true })
|
|
112
131
|
el.addEventListener('animationend', done, { once: true })
|
|
113
132
|
// Safety timeout: if CSS animation never fires (bad CSS, off-screen), force cleanup
|
|
@@ -116,6 +135,8 @@ export function Transition(props: TransitionProps): VNodeChild {
|
|
|
116
135
|
}
|
|
117
136
|
|
|
118
137
|
const applyLeave = (el: HTMLElement) => {
|
|
138
|
+
pendingEnterCancel?.()
|
|
139
|
+
pendingEnterCancel = null
|
|
119
140
|
props.onBeforeLeave?.(el)
|
|
120
141
|
el.classList.remove(cls.ef, cls.ea, cls.et)
|
|
121
142
|
el.classList.add(cls.lf, cls.la)
|
|
@@ -181,6 +202,10 @@ export function Transition(props: TransitionProps): VNodeChild {
|
|
|
181
202
|
})
|
|
182
203
|
|
|
183
204
|
onUnmount(() => {
|
|
205
|
+
// Cancel both pending transitions so neither fires its onAfterX
|
|
206
|
+
// callback on a now-detached element after the 5s safety window.
|
|
207
|
+
pendingEnterCancel?.()
|
|
208
|
+
pendingEnterCancel = null
|
|
184
209
|
pendingLeaveCancel?.()
|
|
185
210
|
pendingLeaveCancel = null
|
|
186
211
|
})
|