@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.
@@ -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
  })
@@ -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()) entry.cleanup()
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 an in-progress leave when re-entering before the animation ends
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
  })