@pyreon/runtime-dom 0.24.5 → 0.24.6

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.
Files changed (53) hide show
  1. package/package.json +5 -9
  2. package/src/delegate.ts +0 -98
  3. package/src/devtools.ts +0 -339
  4. package/src/env.d.ts +0 -6
  5. package/src/hydrate.ts +0 -450
  6. package/src/hydration-debug.ts +0 -129
  7. package/src/index.ts +0 -83
  8. package/src/keep-alive-entry.ts +0 -3
  9. package/src/keep-alive.ts +0 -83
  10. package/src/manifest.ts +0 -236
  11. package/src/mount.ts +0 -597
  12. package/src/nodes.ts +0 -896
  13. package/src/props.ts +0 -474
  14. package/src/template.ts +0 -523
  15. package/src/tests/callback-ref-unmount.browser.test.ts +0 -62
  16. package/src/tests/callback-ref-unmount.test.ts +0 -52
  17. package/src/tests/compiler-integration.test.tsx +0 -508
  18. package/src/tests/coverage-gaps.test.ts +0 -3183
  19. package/src/tests/coverage.test.ts +0 -1140
  20. package/src/tests/ctx-stack-growth-repro.test.tsx +0 -158
  21. package/src/tests/dev-gate-pattern.test.ts +0 -46
  22. package/src/tests/dev-gate-treeshake.test.ts +0 -256
  23. package/src/tests/error-boundary-stack-leak-repro.test.tsx +0 -133
  24. package/src/tests/fanout-repro.test.tsx +0 -219
  25. package/src/tests/hydration-integration.test.tsx +0 -540
  26. package/src/tests/keyed-array-in-for-batched-toggle.browser.test.ts +0 -140
  27. package/src/tests/lifecycle-integration.test.tsx +0 -342
  28. package/src/tests/lis-prepend.browser.test.ts +0 -99
  29. package/src/tests/manifest-snapshot.test.ts +0 -85
  30. package/src/tests/mount.test.ts +0 -3529
  31. package/src/tests/native-markers.test.ts +0 -19
  32. package/src/tests/props.test.ts +0 -581
  33. package/src/tests/reactive-props.test.ts +0 -270
  34. package/src/tests/real-world-integration.test.tsx +0 -714
  35. package/src/tests/rs-collapse-dyn-h.browser.test.ts +0 -303
  36. package/src/tests/rs-collapse-dyn.browser.test.ts +0 -316
  37. package/src/tests/rs-collapse-h.browser.test.ts +0 -152
  38. package/src/tests/rs-collapse-h.test.ts +0 -237
  39. package/src/tests/rs-collapse.browser.test.ts +0 -128
  40. package/src/tests/runtime-dom.browser.test.ts +0 -409
  41. package/src/tests/setup.ts +0 -3
  42. package/src/tests/show-context.test.ts +0 -270
  43. package/src/tests/show-of-for-batched-toggle.browser.test.ts +0 -122
  44. package/src/tests/ssr-xss-round-trip.browser.test.ts +0 -93
  45. package/src/tests/style-key-removal.browser.test.ts +0 -54
  46. package/src/tests/style-key-removal.test.ts +0 -88
  47. package/src/tests/template.test.ts +0 -383
  48. package/src/tests/transition-timeout-leak.test.ts +0 -126
  49. package/src/tests/transition.test.ts +0 -568
  50. package/src/tests/verified-correct-probes.test.ts +0 -56
  51. package/src/transition-entry.ts +0 -7
  52. package/src/transition-group.ts +0 -350
  53. package/src/transition.ts +0 -245
@@ -1,350 +0,0 @@
1
- import type { Props, VNode, VNodeChild } from '@pyreon/core'
2
- import { createRef, h, nativeCompat, onMount, onUnmount } from '@pyreon/core'
3
- import { effect, runUntracked, signal } from '@pyreon/reactivity'
4
- import { mountChild } from './mount'
5
-
6
- export interface TransitionGroupProps<T = unknown> {
7
- /** Wrapper element tag. Default: "div" */
8
- tag?: string
9
- /** CSS class prefix. Default: "pyreon" */
10
- name?: string
11
- /** Animate items on initial mount. Default: false */
12
- appear?: boolean
13
- // CSS class overrides
14
- enterFrom?: string
15
- enterActive?: string
16
- enterTo?: string
17
- leaveFrom?: string
18
- leaveActive?: string
19
- leaveTo?: string
20
- /** Class applied during FLIP move animation. Default: "{name}-move" */
21
- moveClass?: string
22
- /** Reactive list source */
23
- items: () => T[]
24
- /** Stable key extractor */
25
- keyFn: (item: T, index: number) => string | number
26
- /**
27
- * Render a single DOM-element VNode for each item.
28
- * Must return a VNode whose `type` is a string (e.g. "div", "li") so
29
- * the component can inject a ref and read the underlying DOM node.
30
- */
31
- render: (item: T, index: number) => VNode
32
- // Lifecycle callbacks
33
- onBeforeEnter?: (el: HTMLElement) => void
34
- onAfterEnter?: (el: HTMLElement) => void
35
- onBeforeLeave?: (el: HTMLElement) => void
36
- onAfterLeave?: (el: HTMLElement) => void
37
- }
38
-
39
- type ItemEntry = {
40
- key: string | number
41
- ref: ReturnType<typeof createRef<HTMLElement>>
42
- cleanup: () => void
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
53
- }
54
-
55
- /**
56
- * TransitionGroup — animates a keyed reactive list with CSS enter/leave and
57
- * FLIP move animations.
58
- *
59
- * Class lifecycle:
60
- * Enter: {name}-enter-from → {name}-enter-active + {name}-enter-to → cleanup
61
- * Leave: {name}-leave-from → {name}-leave-active + {name}-leave-to → item removed
62
- * Move: {name}-move (applied when an item shifts position)
63
- *
64
- * @example
65
- * const items = signal([{ id: 1 }, { id: 2 }])
66
- *
67
- * h(TransitionGroup, {
68
- * tag: "ul",
69
- * name: "list",
70
- * items,
71
- * keyFn: (item) => item.id,
72
- * render: (item) => h("li", { class: "item" }, item.id),
73
- * })
74
- *
75
- * // CSS:
76
- * // .list-enter-from, .list-leave-to { opacity: 0; transform: translateY(-10px); }
77
- * // .list-enter-active, .list-leave-active { transition: all 300ms ease; }
78
- * // .list-move { transition: transform 300ms ease; }
79
- */
80
- export function TransitionGroup<T = unknown>(props: TransitionGroupProps<T>): VNodeChild {
81
- const tag = props.tag ?? 'div'
82
- const n = props.name ?? 'pyreon'
83
- const cls = {
84
- ef: props.enterFrom ?? `${n}-enter-from`,
85
- ea: props.enterActive ?? `${n}-enter-active`,
86
- et: props.enterTo ?? `${n}-enter-to`,
87
- lf: props.leaveFrom ?? `${n}-leave-from`,
88
- la: props.leaveActive ?? `${n}-leave-active`,
89
- lt: props.leaveTo ?? `${n}-leave-to`,
90
- mv: props.moveClass ?? `${n}-move`,
91
- }
92
-
93
- const containerRef = createRef<HTMLElement>()
94
- const entries = new Map<string | number, ItemEntry>()
95
- // Gates the effect until the container element is in the DOM
96
- const ready = signal(false)
97
- let firstRun = true
98
-
99
- const applyEnter = (entry: ItemEntry, el: HTMLElement) => {
100
- props.onBeforeEnter?.(el)
101
- el.classList.remove(cls.lf, cls.la, cls.lt)
102
- el.classList.add(cls.ef, cls.ea)
103
- requestAnimationFrame(() => {
104
- el.classList.remove(cls.ef)
105
- el.classList.add(cls.et)
106
- let safetyTimer: ReturnType<typeof setTimeout> | null = null
107
- const done = () => {
108
- el.removeEventListener('transitionend', done)
109
- el.removeEventListener('animationend', done)
110
- if (safetyTimer !== null) {
111
- clearTimeout(safetyTimer)
112
- safetyTimer = null
113
- }
114
- entry.cancelTransition = null
115
- el.classList.remove(cls.ea, cls.et)
116
- props.onAfterEnter?.(el)
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
- }
127
- el.addEventListener('transitionend', done, { once: true })
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)
133
- })
134
- }
135
-
136
- const applyLeave = (entry: ItemEntry, el: HTMLElement, onDone: () => void) => {
137
- props.onBeforeLeave?.(el)
138
- el.classList.remove(cls.ef, cls.ea, cls.et)
139
- el.classList.add(cls.lf, cls.la)
140
- requestAnimationFrame(() => {
141
- el.classList.remove(cls.lf)
142
- el.classList.add(cls.lt)
143
- let safetyTimer: ReturnType<typeof setTimeout> | null = null
144
- const done = () => {
145
- el.removeEventListener('transitionend', done)
146
- el.removeEventListener('animationend', done)
147
- if (safetyTimer !== null) {
148
- clearTimeout(safetyTimer)
149
- safetyTimer = null
150
- }
151
- entry.cancelTransition = null
152
- el.classList.remove(cls.la, cls.lt)
153
- props.onAfterLeave?.(el)
154
- onDone()
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
- }
165
- el.addEventListener('transitionend', done, { once: true })
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)
173
- })
174
- }
175
-
176
- /** Start leave animation for removed items. */
177
- const processLeaves = (newKeys: Set<string | number>) => {
178
- for (const [key, entry] of entries) {
179
- if (newKeys.has(key) || entry.leaving) continue
180
- entry.leaving = true
181
- const el = entry.ref.current
182
- if (el) {
183
- applyLeave(entry, el, () => {
184
- entry.cleanup()
185
- entries.delete(key)
186
- })
187
- } else {
188
- entry.cleanup()
189
- entries.delete(key)
190
- }
191
- }
192
- }
193
-
194
- /** Mount new items and return the list of newly created entries. */
195
- const mountNewItems = (items: T[], container: HTMLElement): ItemEntry[] => {
196
- const newEntries: ItemEntry[] = []
197
- for (let i = 0; i < items.length; i++) {
198
- const item = items[i] as T
199
- const key = props.keyFn(item, i)
200
- if (entries.has(key)) continue
201
- const itemRef = createRef<HTMLElement>()
202
- // Both render AND mountChild must run untracked — child component
203
- // setup (signal reads inside the render callback's resulting tree,
204
- // useTheme / useQuery's options() construction etc.) must NOT
205
- // subscribe this effect. Otherwise an unrelated signal flip re-runs
206
- // the TransitionGroup effect, runCleanup() disposes the children's
207
- // inner effects, and the next mount path skips re-rendering kept
208
- // entries → the inner reactivity is lost. Same shape as the
209
- // mountFor / mountKeyedList fix in nodes.ts.
210
- const cleanup = runUntracked(() => {
211
- const rawVNode = props.render(item, i)
212
- const vnode: VNode =
213
- typeof rawVNode.type === 'string'
214
- ? { ...rawVNode, props: { ...rawVNode.props, ref: itemRef } as Props }
215
- : rawVNode
216
- return mountChild(vnode, container, null)
217
- })
218
- const entry: ItemEntry = { key, ref: itemRef, cleanup, leaving: false, cancelTransition: null }
219
- entries.set(key, entry)
220
- newEntries.push(entry)
221
- }
222
- return newEntries
223
- }
224
-
225
- const startMoveAnimation = (entry: ItemEntry, el: HTMLElement) => {
226
- requestAnimationFrame(() => {
227
- el.classList.add(cls.mv)
228
- el.style.transform = ''
229
- el.style.transition = ''
230
- let safetyTimer: ReturnType<typeof setTimeout> | null = null
231
- const done = () => {
232
- el.removeEventListener('transitionend', done)
233
- el.removeEventListener('animationend', done)
234
- if (safetyTimer !== null) {
235
- clearTimeout(safetyTimer)
236
- safetyTimer = null
237
- }
238
- entry.cancelTransition = null
239
- el.classList.remove(cls.mv)
240
- }
241
- entry.cancelTransition = () => {
242
- el.removeEventListener('transitionend', done)
243
- el.removeEventListener('animationend', done)
244
- if (safetyTimer !== null) {
245
- clearTimeout(safetyTimer)
246
- safetyTimer = null
247
- }
248
- el.classList.remove(cls.mv)
249
- }
250
- el.addEventListener('transitionend', done, { once: true })
251
- el.addEventListener('animationend', done, { once: true })
252
- safetyTimer = setTimeout(done, 5000)
253
- })
254
- }
255
-
256
- const flipEntry = (entry: ItemEntry, oldPos: DOMRect) => {
257
- if (!entry.ref.current) return
258
- const newPos = entry.ref.current.getBoundingClientRect()
259
- const dx = oldPos.left - newPos.left
260
- const dy = oldPos.top - newPos.top
261
- if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return
262
- const el = entry.ref.current
263
- el.style.transform = `translate(${dx}px, ${dy}px)`
264
- el.style.transition = 'none'
265
- startMoveAnimation(entry, el)
266
- }
267
-
268
- /** Apply FLIP move animations for items that shifted position. */
269
- const applyFlipMoves = (oldPositions: Map<string | number, DOMRect>) => {
270
- requestAnimationFrame(() => {
271
- for (const [key, entry] of entries) {
272
- if (entry.leaving) continue
273
- const oldPos = oldPositions.get(key)
274
- if (!oldPos) continue
275
- flipEntry(entry, oldPos)
276
- }
277
- })
278
- }
279
-
280
- const recordOldPositions = (): Map<string | number, DOMRect> => {
281
- const oldPositions = new Map<string | number, DOMRect>()
282
- for (const [key, entry] of entries) {
283
- if (!entry.leaving && entry.ref.current) {
284
- oldPositions.set(key, entry.ref.current.getBoundingClientRect())
285
- }
286
- }
287
- return oldPositions
288
- }
289
-
290
- const reorderEntries = (items: T[], container: HTMLElement) => {
291
- for (let i = 0; i < items.length; i++) {
292
- const key = props.keyFn(items[i] as T, i)
293
- const entry = entries.get(key)
294
- if (!entry || entry.leaving || !entry.ref.current) continue
295
- container.appendChild(entry.ref.current)
296
- }
297
- }
298
-
299
- const animateNewEntries = (newEntries: ItemEntry[]) => {
300
- for (const entry of newEntries) {
301
- queueMicrotask(() => {
302
- if (entry.ref.current) applyEnter(entry, entry.ref.current)
303
- })
304
- }
305
- }
306
-
307
- const e = effect(() => {
308
- if (!ready()) return
309
- const container = containerRef.current
310
- if (!container) return
311
-
312
- const items = props.items()
313
- const newKeys = new Set(items.map((item, i) => props.keyFn(item, i)))
314
- const isFirst = firstRun
315
- firstRun = false
316
-
317
- const oldPositions = recordOldPositions()
318
- processLeaves(newKeys)
319
- const newEntries = mountNewItems(items, container)
320
- reorderEntries(items, container)
321
-
322
- if (!isFirst || props.appear) animateNewEntries(newEntries)
323
- if (!isFirst && oldPositions.size > 0) applyFlipMoves(oldPositions)
324
- })
325
-
326
- // Fire the effect once the container is in the DOM
327
- onMount(() => {
328
- ready.set(true)
329
- })
330
-
331
- onUnmount(() => {
332
- e.dispose()
333
- for (const entry of entries.values()) {
334
- // Cancel any in-progress enter/leave/move transition so the 5s
335
- // safety timer doesn't keep running past container unmount and
336
- // onAfterEnter / onAfterLeave don't fire on a detached element.
337
- entry.cancelTransition?.()
338
- entry.cancelTransition = null
339
- entry.cleanup()
340
- }
341
- entries.clear()
342
- })
343
-
344
- return h(tag, { ref: containerRef })
345
- }
346
-
347
- // Mark as native so compat-mode jsx() runtimes skip wrapCompatComponent —
348
- // TransitionGroup uses signal/effect/onMount/onUnmount + mountChild that
349
- // need Pyreon's setup frame.
350
- nativeCompat(TransitionGroup)
package/src/transition.ts DELETED
@@ -1,245 +0,0 @@
1
- import type { Props, VNode, VNodeChild } from '@pyreon/core'
2
- import { createRef, Fragment, h, nativeCompat, onUnmount } from '@pyreon/core'
3
- import { effect, runUntracked, signal } from '@pyreon/reactivity'
4
-
5
- // Dev-mode gate: `import.meta.env.DEV` is the Vite/Rolldown standard,
6
- // literal-replaced at build time. The previous `typeof process !== 'undefined'`
7
- // pattern was dead code in real Vite browser bundles because Vite does not
8
- // polyfill `process` for the client — every wrapped warning silently never
9
- // fired in dev. Enforced by the `pyreon/no-process-dev-gate` lint rule.
10
- const __DEV__ = process.env.NODE_ENV !== 'production'
11
-
12
- export interface TransitionProps {
13
- /**
14
- * CSS class name prefix.
15
- * "fade" → fade-enter-from, fade-enter-active, fade-enter-to, fade-leave-from, …
16
- * Default: "pyreon"
17
- */
18
- name?: string
19
- /** Reactive boolean controlling whether the child is shown. */
20
- show: () => boolean
21
- /**
22
- * If true, runs the enter transition on the initial mount (instead of
23
- * appearing immediately). Default: false.
24
- */
25
- appear?: boolean
26
- // Individual class name overrides (override the prefix-based defaults)
27
- enterFrom?: string
28
- enterActive?: string
29
- enterTo?: string
30
- leaveFrom?: string
31
- leaveActive?: string
32
- leaveTo?: string
33
- // Lifecycle callbacks
34
- onBeforeEnter?: (el: HTMLElement) => void
35
- onAfterEnter?: (el: HTMLElement) => void
36
- onBeforeLeave?: (el: HTMLElement) => void
37
- onAfterLeave?: (el: HTMLElement) => void
38
- /**
39
- * The single child element to animate.
40
- * Must be a direct DOM element VNode (not a component) for class injection to work.
41
- */
42
- children?: VNodeChild
43
- }
44
-
45
- /**
46
- * Transition — adds CSS enter/leave animation classes to a single child element,
47
- * controlled by the reactive `show` prop.
48
- *
49
- * Class lifecycle:
50
- * Enter: {name}-enter-from → (next frame) → {name}-enter-active + {name}-enter-to → cleanup
51
- * Leave: {name}-leave-from → (next frame) → {name}-leave-active + {name}-leave-to → unmount
52
- *
53
- * The child element stays in the DOM during the leave animation and is removed only
54
- * after the CSS transition / animation completes.
55
- *
56
- * @example
57
- * const visible = signal(false)
58
- *
59
- * h(Transition, { name: "fade", show: () => visible() },
60
- * h("div", { class: "modal" }, "content")
61
- * )
62
- *
63
- * // CSS:
64
- * // .fade-enter-from, .fade-leave-to { opacity: 0; }
65
- * // .fade-enter-active, .fade-leave-active { transition: opacity 300ms ease; }
66
- */
67
- export function Transition(props: TransitionProps): VNodeChild {
68
- const n = props.name ?? 'pyreon'
69
- const cls = {
70
- ef: props.enterFrom ?? `${n}-enter-from`,
71
- ea: props.enterActive ?? `${n}-enter-active`,
72
- et: props.enterTo ?? `${n}-enter-to`,
73
- lf: props.leaveFrom ?? `${n}-leave-from`,
74
- la: props.leaveActive ?? `${n}-leave-active`,
75
- lt: props.leaveTo ?? `${n}-leave-to`,
76
- }
77
-
78
- // Ref injected into the child element so we can apply/remove classes
79
- const ref = createRef<HTMLElement>()
80
- const isMounted = signal(runUntracked<boolean>(props.show))
81
-
82
- // Cancel in-progress enter / leave when the component unmounts or when a
83
- // new transition supersedes the current one. Both are set inside their
84
- // respective applyX(). Calling the cancel removes event listeners, clears
85
- // the safety timer, and strips active-state classes — WITHOUT firing the
86
- // onAfterX callback (which would run on a detached element after unmount).
87
- let pendingEnterCancel: (() => void) | null = null
88
- let pendingLeaveCancel: (() => void) | null = null
89
- let initialized = false
90
-
91
- const applyEnter = (el: HTMLElement) => {
92
- pendingLeaveCancel?.()
93
- pendingLeaveCancel = null
94
- pendingEnterCancel?.()
95
- pendingEnterCancel = null
96
- props.onBeforeEnter?.(el)
97
- el.classList.remove(cls.lf, cls.la, cls.lt)
98
- el.classList.add(cls.ef, cls.ea)
99
- requestAnimationFrame(() => {
100
- el.classList.remove(cls.ef)
101
- el.classList.add(cls.et)
102
- let safetyTimer: ReturnType<typeof setTimeout> | null = null
103
- const done = () => {
104
- // Remove both listeners — only one fires, so clean up the other
105
- el.removeEventListener('transitionend', done)
106
- el.removeEventListener('animationend', done)
107
- // Clear the safety timeout — without this, when transitionend fires
108
- // normally the 5s timer would still fire later and re-invoke done(),
109
- // leaking timer refs and re-firing onAfterEnter.
110
- if (safetyTimer !== null) {
111
- clearTimeout(safetyTimer)
112
- safetyTimer = null
113
- }
114
- pendingEnterCancel = null
115
- el.classList.remove(cls.ea, cls.et)
116
- props.onAfterEnter?.(el)
117
- }
118
- // Cancel path (called from onUnmount or a superseding transition): tears
119
- // down without firing onAfterEnter on a detached element.
120
- pendingEnterCancel = () => {
121
- el.removeEventListener('transitionend', done)
122
- el.removeEventListener('animationend', done)
123
- if (safetyTimer !== null) {
124
- clearTimeout(safetyTimer)
125
- safetyTimer = null
126
- }
127
- el.classList.remove(cls.ef, cls.ea, cls.et)
128
- }
129
- el.addEventListener('transitionend', done, { once: true })
130
- el.addEventListener('animationend', done, { once: true })
131
- // Safety timeout: if CSS animation never fires (bad CSS, off-screen), force cleanup
132
- safetyTimer = setTimeout(done, 5000)
133
- })
134
- }
135
-
136
- const applyLeave = (el: HTMLElement) => {
137
- pendingEnterCancel?.()
138
- pendingEnterCancel = null
139
- props.onBeforeLeave?.(el)
140
- el.classList.remove(cls.ef, cls.ea, cls.et)
141
- el.classList.add(cls.lf, cls.la)
142
- requestAnimationFrame(() => {
143
- el.classList.remove(cls.lf)
144
- el.classList.add(cls.lt)
145
- let safetyTimer: ReturnType<typeof setTimeout> | null = null
146
- const done = () => {
147
- // Remove both listeners — only one fires, so clean up the other
148
- el.removeEventListener('transitionend', done)
149
- el.removeEventListener('animationend', done)
150
- // Clear the safety timeout (see applyEnter for rationale).
151
- if (safetyTimer !== null) {
152
- clearTimeout(safetyTimer)
153
- safetyTimer = null
154
- }
155
- el.classList.remove(cls.la, cls.lt)
156
- pendingLeaveCancel = null
157
- isMounted.set(false)
158
- props.onAfterLeave?.(el)
159
- }
160
- pendingLeaveCancel = () => {
161
- el.removeEventListener('transitionend', done)
162
- el.removeEventListener('animationend', done)
163
- if (safetyTimer !== null) {
164
- clearTimeout(safetyTimer)
165
- safetyTimer = null
166
- }
167
- el.classList.remove(cls.lf, cls.la, cls.lt)
168
- }
169
- el.addEventListener('transitionend', done, { once: true })
170
- el.addEventListener('animationend', done, { once: true })
171
- // Safety timeout: if CSS animation never fires, force cleanup
172
- safetyTimer = setTimeout(done, 5000)
173
- })
174
- }
175
-
176
- const handleVisibilityChange = (visible: boolean) => {
177
- if (visible) {
178
- if (!isMounted.peek()) isMounted.set(true)
179
- queueMicrotask(() => applyEnter(ref.current as HTMLElement))
180
- return
181
- }
182
- if (!isMounted.peek()) return
183
- const el = ref.current
184
- if (!el) {
185
- isMounted.set(false)
186
- return
187
- }
188
- applyLeave(el)
189
- }
190
-
191
- // queueMicrotask defers the appear-animation to after the DOM has
192
- // committed the initial mount — that scheduling IS the reactive
193
- // subscription's job here (it tracks `props.show()` and `props.appear`
194
- // and schedules the visual transition). Not setup work.
195
- // pyreon-lint-disable-next-line pyreon/no-imperative-effect-on-create
196
- effect(() => {
197
- const visible = props.show()
198
- if (!initialized) {
199
- initialized = true
200
- if (visible && props.appear) {
201
- queueMicrotask(() => applyEnter(ref.current as HTMLElement))
202
- }
203
- return
204
- }
205
- handleVisibilityChange(visible)
206
- })
207
-
208
- onUnmount(() => {
209
- // Cancel both pending transitions so neither fires its onAfterX
210
- // callback on a now-detached element after the 5s safety window.
211
- pendingEnterCancel?.()
212
- pendingEnterCancel = null
213
- pendingLeaveCancel?.()
214
- pendingLeaveCancel = null
215
- })
216
-
217
- // Return a reactive getter. Each call clones the child VNode with our injected ref
218
- // so we can read / write classes on the underlying DOM element.
219
- const rawChild = props.children
220
- // Return an empty Fragment (not null) when unmounted so mountChild uses
221
- // mountReactive instead of the null/primitive text-node fast-path, which
222
- // cannot later be swapped for a VNode when the element enters.
223
- const emptyFragment = h(Fragment, null)
224
- return (() => {
225
- if (!isMounted()) return emptyFragment
226
- if (!rawChild || typeof rawChild !== 'object' || Array.isArray(rawChild)) {
227
- return rawChild ?? null
228
- }
229
- const vnode = rawChild as VNode
230
- // Only inject ref into DOM element children — component children need ref forwarding
231
- if (typeof vnode.type !== 'string') {
232
- if (__DEV__) {
233
- console.warn(
234
- '[Pyreon] Transition child is a component. Wrap it in a DOM element for enter/leave animations to work.',
235
- )
236
- }
237
- return vnode
238
- }
239
- return { ...vnode, props: { ...vnode.props, ref } as Props }
240
- }) as unknown as VNode
241
- }
242
-
243
- // Mark as native so compat-mode jsx() runtimes skip wrapCompatComponent —
244
- // Transition uses signal/effect/onUnmount that need Pyreon's setup frame.
245
- nativeCompat(Transition)