@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,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
+ })
@@ -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,44 +80,79 @@ 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)
93
100
  requestAnimationFrame(() => {
94
101
  el.classList.remove(cls.ef)
95
102
  el.classList.add(cls.et)
103
+ let safetyTimer: ReturnType<typeof setTimeout> | null = null
96
104
  const done = () => {
97
105
  // Remove both listeners — only one fires, so clean up the other
98
106
  el.removeEventListener('transitionend', done)
99
107
  el.removeEventListener('animationend', done)
108
+ // Clear the safety timeout — without this, when transitionend fires
109
+ // normally the 5s timer would still fire later and re-invoke done(),
110
+ // leaking timer refs and re-firing onAfterEnter.
111
+ if (safetyTimer !== null) {
112
+ clearTimeout(safetyTimer)
113
+ safetyTimer = null
114
+ }
115
+ pendingEnterCancel = null
100
116
  el.classList.remove(cls.ea, cls.et)
101
117
  props.onAfterEnter?.(el)
102
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
+ }
103
130
  el.addEventListener('transitionend', done, { once: true })
104
131
  el.addEventListener('animationend', done, { once: true })
105
132
  // Safety timeout: if CSS animation never fires (bad CSS, off-screen), force cleanup
106
- setTimeout(done, 5000)
133
+ safetyTimer = setTimeout(done, 5000)
107
134
  })
108
135
  }
109
136
 
110
137
  const applyLeave = (el: HTMLElement) => {
138
+ pendingEnterCancel?.()
139
+ pendingEnterCancel = null
111
140
  props.onBeforeLeave?.(el)
112
141
  el.classList.remove(cls.ef, cls.ea, cls.et)
113
142
  el.classList.add(cls.lf, cls.la)
114
143
  requestAnimationFrame(() => {
115
144
  el.classList.remove(cls.lf)
116
145
  el.classList.add(cls.lt)
146
+ let safetyTimer: ReturnType<typeof setTimeout> | null = null
117
147
  const done = () => {
118
148
  // Remove both listeners — only one fires, so clean up the other
119
149
  el.removeEventListener('transitionend', done)
120
150
  el.removeEventListener('animationend', done)
151
+ // Clear the safety timeout (see applyEnter for rationale).
152
+ if (safetyTimer !== null) {
153
+ clearTimeout(safetyTimer)
154
+ safetyTimer = null
155
+ }
121
156
  el.classList.remove(cls.la, cls.lt)
122
157
  pendingLeaveCancel = null
123
158
  isMounted.set(false)
@@ -126,12 +161,16 @@ export function Transition(props: TransitionProps): VNodeChild {
126
161
  pendingLeaveCancel = () => {
127
162
  el.removeEventListener('transitionend', done)
128
163
  el.removeEventListener('animationend', done)
164
+ if (safetyTimer !== null) {
165
+ clearTimeout(safetyTimer)
166
+ safetyTimer = null
167
+ }
129
168
  el.classList.remove(cls.lf, cls.la, cls.lt)
130
169
  }
131
170
  el.addEventListener('transitionend', done, { once: true })
132
171
  el.addEventListener('animationend', done, { once: true })
133
172
  // Safety timeout: if CSS animation never fires, force cleanup
134
- setTimeout(done, 5000)
173
+ safetyTimer = setTimeout(done, 5000)
135
174
  })
136
175
  }
137
176
 
@@ -163,6 +202,10 @@ export function Transition(props: TransitionProps): VNodeChild {
163
202
  })
164
203
 
165
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
166
209
  pendingLeaveCancel?.()
167
210
  pendingLeaveCancel = null
168
211
  })