@pyreon/kinetic 0.21.0 → 0.23.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.
@@ -397,14 +397,35 @@ const wireWrapperRef = (vnode: VNode | null, el: HTMLElement) => {
397
397
  }
398
398
  }
399
399
 
400
- /** Find and wire contentRef inside Show > div children. */
400
+ /**
401
+ * Find and wire contentRef on the inner div. Walks two shapes:
402
+ *
403
+ * 1. Initially-visible Collapse: outer → <Show>{<div ref=contentRef/>}</Show>
404
+ * (the pre-SSR-fix shape, kept for the `wasInitiallyShown=true` branch
405
+ * in CollapseRenderer)
406
+ *
407
+ * 2. Initially-hidden Collapse: outer → <div ref=contentRef/>
408
+ * (the SSR-correct shape — children always rendered structurally so
409
+ * the prerendered HTML carries content for SEO / social scrapers /
410
+ * no-JS users; visual hiding via the outer wrapper's `height: 0;
411
+ * overflow: hidden`. See CollapseRenderer's `wasInitiallyShown`
412
+ * branch.)
413
+ *
414
+ * Tries the direct-div shape first; falls back to the Show-wrapped walk.
415
+ */
401
416
  const wireContentRef = (vnode: VNode | null, contentEl: HTMLElement) => {
402
417
  if (!vnode?.children) return
403
418
  const vnodeChildren = Array.isArray(vnode.children) ? vnode.children : [vnode.children]
404
419
  for (const c of vnodeChildren) {
405
- if (!c || typeof c !== 'object' || !('type' in (c as object))) continue
406
- const showNode = c as any
407
- const showChildren = showNode.props?.children ?? showNode.children
420
+ if (!c || typeof c !== 'object' || !('props' in (c as object))) continue
421
+ const directRef = (c as any).props?.ref
422
+ if (directRef) {
423
+ if (typeof directRef === 'function') directRef(contentEl)
424
+ else if (typeof directRef === 'object') directRef.current = contentEl
425
+ return
426
+ }
427
+ // Fall through to Show-wrapped walk.
428
+ const showChildren = (c as any).props?.children ?? (c as any).children
408
429
  if (!showChildren) continue
409
430
  const sc = Array.isArray(showChildren) ? showChildren : [showChildren]
410
431
  for (const s of sc) {
@@ -111,6 +111,34 @@ describe('Transition — SSR / initially-hidden children render', () => {
111
111
  expect(html).toContain('translateY(20px)')
112
112
  })
113
113
 
114
+ it('falls back to `enterStyle` as hidden style when leaveToStyle undefined (preset path)', async () => {
115
+ // The preset shape — `@pyreon/kinetic-presets` factories (fadeUp,
116
+ // blurInUp, slideLeft, …) populate `enterStyle` as the hidden state
117
+ // but may not set `leaveToStyle`. PR #717 shipped the
118
+ // `wasInitiallyShown` branch with `hiddenStyle = props.leaveToStyle`
119
+ // alone — so preset users SSR-rendered VISIBLE → flash-on-hydration.
120
+ // This regression test locks in the `?? props.enterStyle` fallback
121
+ // that aligns the style picker with the existing
122
+ // `hiddenClass = leaveTo ?? enterFrom` class picker.
123
+ //
124
+ // The companion `kinetic(tag).<mode>` paths (TransitionRenderer /
125
+ // TransitionItem / CollapseRenderer) got the same fallback in #719;
126
+ // this commit closes the matching gap on the direct `<Transition>`
127
+ // import path.
128
+ const html = await renderToString(
129
+ h(Transition, {
130
+ show: () => false,
131
+ enter: 'transition-all duration-300',
132
+ enterStyle: { opacity: 0, transform: 'translateY(16px)' },
133
+ enterToStyle: { opacity: 1, transform: 'translateY(0)' },
134
+ children: h('section', null, 'preset-shaped hidden state'),
135
+ }),
136
+ )
137
+ expect(html).toContain('preset-shaped hidden state')
138
+ expect(html).toContain('opacity: 0')
139
+ expect(html).toContain('translateY(16px)')
140
+ })
141
+
114
142
  it('merges the hidden class with any user-set class on the child', async () => {
115
143
  const html = await renderToString(
116
144
  h(Transition, {
@@ -0,0 +1,214 @@
1
+ /**
2
+ * SSR regression coverage for the `kinetic(tag).<mode>` API — the three
3
+ * renderer files PR #717 didn't reach:
4
+ *
5
+ * - `TransitionRenderer` → `kinetic('div').preset(...)` (default
6
+ * `.transition` mode — the README's main example)
7
+ * - `TransitionItem` → `kinetic('ul').stagger()` per item (the
8
+ * cascade-children mode); transitively `kinetic('ul').group()`
9
+ * - `CollapseRenderer` → `kinetic('div').collapse()` (height-animation
10
+ * mode)
11
+ *
12
+ * Background. The top-level `<Transition>` was fixed in PR #717. But the
13
+ * `kinetic(tag).<mode>` API — which the README promotes as the primary
14
+ * surface — has its own per-mode renderers, and all three carried the
15
+ * SAME `<Show when={shouldMount} fallback={null}>` shape, dropping
16
+ * children from prerendered HTML when `show()` is false at SSR. That
17
+ * meant every documented `kinetic(tag).<mode>` consumer hit the bug
18
+ * even after #717 landed — including the cascading-Stagger pattern this
19
+ * report's author flagged on a real resume page.
20
+ *
21
+ * The fix mirrors #717: branch each renderer at setup on `props.show()`.
22
+ * Initially-visible → existing `<Show>`-gated mount (preserves runtime-
23
+ * unmount semantic). Initially-hidden → always render the inner content
24
+ * with the hidden-state class/style inlined; the existing `watch(stage)`
25
+ * effect drives the enter animation when `show` flips true.
26
+ *
27
+ * Hidden-state picker (mirrors #717): `leaveTo` / `leaveToStyle` win
28
+ * (explicit hidden-end state); fall back to `enterFrom` / `enterStyle`
29
+ * (pre-enter state). The `enterStyle` fallback covers the preset path —
30
+ * `@pyreon/kinetic-presets` factories populate `enterStyle` as the
31
+ * hidden state but may not set `leaveToStyle`. Without the fallback,
32
+ * preset users would SSR-render VISIBLE → flash-on-hydration.
33
+ *
34
+ * API note. `kinetic(tag)` takes animation config via CHAIN methods
35
+ * (`.enter()`, `.enterClass({from, to, active})`, `.leaveClass(...)`,
36
+ * `.preset()`), NOT as runtime props. Runtime props are limited to
37
+ * `show` / `appear` / `unmount` / `timeout` plus HTML attributes —
38
+ * anything else gets forwarded to the rendered element. The tests
39
+ * below use the chain API to faithfully exercise real user code.
40
+ *
41
+ * Coverage layered with PR #717: the test file there
42
+ * (`Transition.ssr.test.tsx`) covers the direct `<Transition>` import
43
+ * path; this file covers the `kinetic(tag).<mode>` factory paths.
44
+ */
45
+
46
+ import { h } from '@pyreon/core'
47
+ import { renderToString } from '@pyreon/runtime-server'
48
+ import { describe, expect, it } from 'vitest'
49
+ import kinetic from '../kinetic'
50
+
51
+ describe('kinetic(tag).transition — SSR / initially-hidden (TransitionRenderer)', () => {
52
+ it('emits children when show=false initially (kinetic-mode shape, was: empty wrapper)', async () => {
53
+ // Cascading-bug shape — every `kinetic('div').preset(...)` user with a
54
+ // scroll-reveal `show` accessor hit this. Pre-fix: outer wrapper renders
55
+ // but children are dropped by the inner Show fallback.
56
+ const FadeSection = kinetic('section').enterClass({
57
+ active: 'transition-all duration-300',
58
+ from: 'opacity-0',
59
+ to: 'opacity-100',
60
+ })
61
+ const html = await renderToString(
62
+ h(FadeSection, { show: () => false },
63
+ h('h2', null, 'Work Experience'),
64
+ h('p', null, 'real content for SEO + social scrapers'),
65
+ ),
66
+ )
67
+ expect(html).toContain('<h2')
68
+ expect(html).toContain('Work Experience')
69
+ expect(html).toContain('real content for SEO + social scrapers')
70
+ })
71
+
72
+ it('inlines `leaveTo` class over `enterFrom` (explicit hidden-end state wins)', async () => {
73
+ const Panel = kinetic('aside')
74
+ .enterClass({ from: 'translate-y-4', to: 'translate-y-0' })
75
+ .leaveClass({ to: 'is-hidden opacity-0' })
76
+ const html = await renderToString(
77
+ h(Panel, { show: () => false },
78
+ h('div', null, 'panel content'),
79
+ ),
80
+ )
81
+ expect(html).toContain('is-hidden opacity-0')
82
+ expect(html).toContain('panel content')
83
+ // leaveTo wins — the competing enterFrom should NOT be applied.
84
+ expect(html).not.toContain('translate-y-4')
85
+ })
86
+
87
+ it('inlines `enterStyle` as hidden style when leaveToStyle undefined (preset path)', async () => {
88
+ // The preset shape — `@pyreon/kinetic-presets` factories populate
89
+ // `enterStyle` (= `.enter()` chain) as the hidden state. Without the
90
+ // enterStyle fallback, SSR would render VISIBLE → flash-on-hydration.
91
+ // This locks in the critical preset-compatibility behaviour.
92
+ const FadeUpDiv = kinetic('div')
93
+ .enter({ opacity: 0, transform: 'translateY(16px)' })
94
+ .enterTo({ opacity: 1, transform: 'translateY(0)' })
95
+ .enterTransition('all 300ms ease-out')
96
+ const html = await renderToString(
97
+ h(FadeUpDiv, { show: () => false },
98
+ h('h1', null, 'preset-shaped hidden state'),
99
+ ),
100
+ )
101
+ expect(html).toContain('preset-shaped hidden state')
102
+ expect(html).toContain('opacity: 0')
103
+ expect(html).toContain('translateY(16px)')
104
+ })
105
+
106
+ it('initially-visible (show=true) renders normally — unchanged behaviour', async () => {
107
+ const FadeDiv = kinetic('div').leaveClass({ to: 'is-hidden' })
108
+ const html = await renderToString(
109
+ h(FadeDiv, { show: () => true },
110
+ h('main', null, 'visible from the start'),
111
+ ),
112
+ )
113
+ expect(html).toContain('visible from the start')
114
+ // leaveTo must NOT leak onto the initially-visible render.
115
+ expect(html).not.toContain('is-hidden')
116
+ })
117
+
118
+ it('falls back to `enterFrom` class for scroll-reveal patterns (only enter side configured)', async () => {
119
+ const RevealSection = kinetic('section').enterClass({
120
+ active: 'transition-all duration-700',
121
+ from: 'opacity-0 translate-y-8',
122
+ to: 'opacity-100 translate-y-0',
123
+ })
124
+ const html = await renderToString(
125
+ h(RevealSection, { show: () => false, id: 'resume-section' },
126
+ h('p', null, 'work history goes here'),
127
+ ),
128
+ )
129
+ expect(html).toContain('id="resume-section"')
130
+ expect(html).toContain('work history goes here')
131
+ expect(html).toContain('opacity-0 translate-y-8')
132
+ })
133
+ })
134
+
135
+ describe('kinetic(tag).stagger() — SSR / initially-hidden (TransitionItem per item)', () => {
136
+ it('emits all child items when show=false initially (cascading stagger SSR shape)', async () => {
137
+ // The reported real-app pattern: cascading intro / list reveal.
138
+ // Pre-fix: every per-item TransitionItem rendered null on the server,
139
+ // dropping the full list from prerendered HTML.
140
+ const StaggerList = kinetic('ul')
141
+ .enterClass({
142
+ active: 'transition-all',
143
+ from: 'opacity-0 translate-y-4',
144
+ to: 'opacity-100 translate-y-0',
145
+ })
146
+ .stagger({ interval: 80 })
147
+ const html = await renderToString(
148
+ h(StaggerList, { show: () => false },
149
+ [
150
+ h('li', { key: 'h' }, 'Heading'),
151
+ h('li', { key: 't' }, 'tagline content'),
152
+ h('li', { key: 's' }, 'social icons row'),
153
+ ],
154
+ ),
155
+ )
156
+ expect(html).toContain('Heading')
157
+ expect(html).toContain('tagline content')
158
+ expect(html).toContain('social icons row')
159
+ // Every per-item TransitionItem should apply the hidden class
160
+ // (enterFrom in this scroll-reveal shape).
161
+ const occurrences = (html.match(/opacity-0 translate-y-4/g) ?? []).length
162
+ expect(occurrences).toBeGreaterThanOrEqual(3)
163
+ })
164
+
165
+ it('initially-visible stagger (show=true) renders all items unchanged', async () => {
166
+ const StaggerList = kinetic('ul')
167
+ .enterClass({ from: 'opacity-0', to: 'opacity-100' })
168
+ .leaveClass({ to: 'is-hidden' })
169
+ .stagger({ interval: 50 })
170
+ const html = await renderToString(
171
+ h(StaggerList, { show: () => true },
172
+ [h('li', { key: 'a' }, 'item-a'), h('li', { key: 'b' }, 'item-b')],
173
+ ),
174
+ )
175
+ expect(html).toContain('item-a')
176
+ expect(html).toContain('item-b')
177
+ // leaveTo must NOT leak onto visible items.
178
+ expect(html).not.toContain('is-hidden')
179
+ })
180
+ })
181
+
182
+ describe('kinetic(tag).collapse() — SSR / initially-hidden (CollapseRenderer)', () => {
183
+ it('emits inner content when show=false initially (was: empty 0-height wrapper)', async () => {
184
+ // Pre-fix: outer wrapper renders with `height: 0; overflow: hidden`
185
+ // but its children are stripped by the inner Show — empty wrapper in
186
+ // prerendered HTML. The fix keeps the outer wrapper's visual hiding
187
+ // (height: 0 IS the layout-safe collapse mechanism — flex slots see
188
+ // a 0-height box, no slot-collapse) while always rendering inner content.
189
+ const Accordion = kinetic('div').collapse()
190
+ const html = await renderToString(
191
+ h(Accordion, { show: () => false },
192
+ h('div', { class: 'panel-body' }, 'accordion panel content for SEO'),
193
+ ),
194
+ )
195
+ expect(html).toContain('accordion panel content for SEO')
196
+ expect(html).toContain('panel-body')
197
+ // The outer wrapper retains the collapse-controlled hidden style —
198
+ // visual hiding via height:0 + overflow:hidden, not by dropping children.
199
+ expect(html).toContain('height: 0px')
200
+ expect(html).toContain('overflow: hidden')
201
+ })
202
+
203
+ it('initially-visible collapse (show=true) renders content normally', async () => {
204
+ const Accordion = kinetic('section').collapse()
205
+ const html = await renderToString(
206
+ h(Accordion, { show: () => true },
207
+ h('p', null, 'expanded content'),
208
+ ),
209
+ )
210
+ expect(html).toContain('expanded content')
211
+ // height: 'auto' is the entered-state hint
212
+ expect(html).toContain('height: auto')
213
+ })
214
+ })
@@ -211,4 +211,117 @@ describe('@pyreon/kinetic browser smoke', () => {
211
211
  expect(el()!.classList.contains('enter-active')).toBe(true)
212
212
  unmount()
213
213
  })
214
+
215
+ // ── Initially-hidden kinetic(tag).<mode> — client-side parity with SSR ──
216
+ //
217
+ // Companion to PR #717's `<Transition>` direct-import specs (the two
218
+ // above). These exercise the `kinetic(tag).<mode>` factory paths — the
219
+ // README's primary documented surface — whose per-mode renderers carried
220
+ // the same SSR-children-dropped bug until this PR fixed them. SSR specs
221
+ // in `kinetic-modes.ssr.test.tsx` prove children land in prerendered
222
+ // HTML; these specs prove the SAME render path works under a real DOM —
223
+ // the element mounts with the hidden-state class/style applied, and an
224
+ // `applyEnter` triggered by a `show` flip cleanly transitions it out.
225
+
226
+ it('kinetic("div").transition with initial show=false mounts element with hidden class', async () => {
227
+ const Reveal = kinetic('section').enterClass({
228
+ active: 'enter-active',
229
+ from: 'hide-state',
230
+ to: 'show-state',
231
+ })
232
+ const show = signal(false)
233
+ const { container, unmount } = mountInBrowser(
234
+ h(Reveal, { show, 'data-id': 'reveal-target' }, h('p', null, 'scroll-reveal content')),
235
+ )
236
+ // Pre-fix: container.querySelector returns null (children dropped).
237
+ const el = container.querySelector('[data-id="reveal-target"]') as HTMLElement | null
238
+ expect(el).not.toBeNull()
239
+ expect(el!.textContent).toContain('scroll-reveal content')
240
+ // enterFrom is the fallback hidden-state class (scroll-reveal pattern
241
+ // configures only the enter side).
242
+ expect(el!.classList.contains('hide-state')).toBe(true)
243
+ unmount()
244
+ })
245
+
246
+ it('kinetic("div").transition show=true flip cleans hidden class + runs enter animation', async () => {
247
+ const Reveal = kinetic('section').enterClass({
248
+ active: 'enter-active',
249
+ from: 'hide-state',
250
+ to: 'show-state',
251
+ })
252
+ const show = signal(false)
253
+ const { container, unmount } = mountInBrowser(
254
+ h(Reveal, { show, 'data-id': 'reveal-target' }, h('p', null, 'content')),
255
+ )
256
+ const el = () => container.querySelector('[data-id="reveal-target"]') as HTMLElement | null
257
+ expect(el()!.classList.contains('hide-state')).toBe(true)
258
+
259
+ show.set(true)
260
+ await flush()
261
+ // Double-rAF for the applyEnter nextFrame → enterTo applied.
262
+ await new Promise<void>((resolve) =>
263
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
264
+ )
265
+ await flush()
266
+
267
+ expect(el()!.classList.contains('show-state')).toBe(true)
268
+ // enterFrom (hide-state) was removed; the symmetric applyEnter cleanup
269
+ // ALSO removes leave-side classes (none here) — locks in the
270
+ // companion fix that prevents residual hidden classes from fighting
271
+ // enterTo's CSS rules.
272
+ expect(el()!.classList.contains('hide-state')).toBe(false)
273
+ expect(el()!.classList.contains('enter-active')).toBe(true)
274
+ unmount()
275
+ })
276
+
277
+ it('kinetic("ul").stagger() with initial show=false mounts all items with hidden class', async () => {
278
+ // The reported real-app cascading-Stagger pattern at SSR. Each per-item
279
+ // TransitionItem must render structurally; the hidden class lands on
280
+ // each item via the enterFrom fallback.
281
+ const Staggered = kinetic('ul')
282
+ .enterClass({ active: 'enter-active', from: 'item-hidden', to: 'item-shown' })
283
+ .stagger({ interval: 50 })
284
+ const show = signal(false)
285
+ const { container, unmount } = mountInBrowser(
286
+ h(Staggered, { show, 'data-id': 'stagger-list' }, [
287
+ h('li', { key: 'a' }, 'first item'),
288
+ h('li', { key: 'b' }, 'second item'),
289
+ h('li', { key: 'c' }, 'third item'),
290
+ ]),
291
+ )
292
+ const list = container.querySelector('[data-id="stagger-list"]') as HTMLElement | null
293
+ expect(list).not.toBeNull()
294
+ const items = list!.querySelectorAll('li')
295
+ expect(items.length).toBe(3)
296
+ // Every per-item TransitionItem applies the hidden class.
297
+ for (const item of items) {
298
+ expect(item.classList.contains('item-hidden')).toBe(true)
299
+ }
300
+ expect(list!.textContent).toContain('first item')
301
+ expect(list!.textContent).toContain('second item')
302
+ expect(list!.textContent).toContain('third item')
303
+ unmount()
304
+ })
305
+
306
+ it('kinetic("div").collapse() with initial show=false mounts inner content (visually hidden via height:0)', async () => {
307
+ // CollapseRenderer's fix: outer wrapper retains height:0 + overflow:hidden
308
+ // (layout-safe visual hiding); inner content is always rendered so SSG
309
+ // ships the structural HTML for SEO. Real-DOM parity check.
310
+ const Accordion = kinetic('div').collapse()
311
+ const show = signal(false)
312
+ const { container, unmount } = mountInBrowser(
313
+ h(Accordion, { show, 'data-id': 'accordion' },
314
+ h('div', { 'data-id': 'inner' }, 'accordion content'),
315
+ ),
316
+ )
317
+ const wrapper = container.querySelector('[data-id="accordion"]') as HTMLElement | null
318
+ const inner = container.querySelector('[data-id="inner"]') as HTMLElement | null
319
+ expect(wrapper).not.toBeNull()
320
+ expect(inner).not.toBeNull() // ← was null pre-fix (Show dropped it)
321
+ expect(inner!.textContent).toBe('accordion content')
322
+ // Outer wrapper visually hides via height:0 (computed style — real CSS).
323
+ expect(wrapper!.style.height).toBe('0px')
324
+ expect(wrapper!.style.overflow).toBe('hidden')
325
+ unmount()
326
+ })
214
327
  })
@@ -0,0 +1,191 @@
1
+ /** @jsxImportSource @pyreon/core */
2
+ /**
3
+ * Regression: `kinetic('div').stagger()` with `show={() => true}` +
4
+ * `appear` + multiple component-VNode children rendered `<undefined>`
5
+ * tags in place of the children's actual DOM post-hydrate.
6
+ *
7
+ * Real-app reporter (examples/bokisch.com Intro section): SSG'd HTML
8
+ * carried `<h1>Hello</h1>` + tagline + icons; client hydration produced
9
+ * `<undefined></undefined>` tags (literal HTML element with tagName
10
+ * "UNDEFINED") + `<!--pyreon-->` markers in place of every child.
11
+ *
12
+ * Bug class — Pyreon-compiler prop-inlining + cloneVNode-on-a-function:
13
+ *
14
+ * 1. The compiler rewrites local `const children = obj.x` then
15
+ * `<Comp>{children}</Comp>` as `Comp({..., children: () => obj.x})`.
16
+ * Component receives `props.children` as a FUNCTION, not an array.
17
+ *
18
+ * 2. StaggerRenderer iterated `(Array.isArray(children) ? children : [children])`
19
+ * directly. `[function].filter(isVNode)` collapsed to `[]` → the
20
+ * kinetic `<div>` rendered with zero children.
21
+ *
22
+ * 3. Even after StaggerRenderer's fix, TransitionItem's `cloneVNode(props.children, {ref})`
23
+ * tried to clone the same function-wrapped value (also auto-wrapped
24
+ * by the compiler one level down — `{cloneVNode(child, {style})}`
25
+ * became `() => cloneVNode(child, {style})`). Spreading a function
26
+ * via `{...fn, props: {...}}` yields `{props: {...}}` (no own
27
+ * enumerable properties on functions) — the resulting vnode had
28
+ * `type: undefined`. mountElement called `document.createElement(undefined)`
29
+ * → the browser produced literal `<undefined>` tags.
30
+ *
31
+ * Fix: `resolveChildren` helper in both StaggerRenderer (iteration) AND
32
+ * TransitionItem (cloning). Unwraps function-wrapped children eagerly
33
+ * since kinetic snapshots children at render time and does not observe
34
+ * children changes.
35
+ *
36
+ * Bisect-verified: reverting either `resolveChildren` call in this PR
37
+ * fails this spec — StaggerRenderer revert produces zero children in the
38
+ * kinetic `<div>`; TransitionItem revert produces `<undefined>` tags
39
+ * with the right cloned style.
40
+ */
41
+ import type { VNode } from '@pyreon/core'
42
+ import { h } from '@pyreon/core'
43
+ import { mount } from '@pyreon/runtime-dom'
44
+ import { afterEach, describe, expect, it } from 'vitest'
45
+ import kinetic from '../kinetic'
46
+ import TransitionItem from '../kinetic/TransitionItem'
47
+
48
+ // Build a kinetic Component VNode whose `props.children` is a FUNCTION
49
+ // (not an array), mirroring what the Pyreon vite-plugin emits when JSX
50
+ // children are inlined back at the call site (`<Entrance>{children}</Entrance>`
51
+ // → `jsx(Entrance, { children: () => h.children })`). The kinetic test
52
+ // pipeline uses `vl_rolldown_build` which does NOT do Pyreon's
53
+ // prop-inlining, so we construct the shape directly.
54
+ const buildEntranceWithFunctionChildren = (
55
+ // Wider call signature — kinetic('div').stagger() returns a stagger-mode
56
+ // component whose precise typed shape (`KineticComponent<'div', 'stagger'>`)
57
+ // is narrower than what `kinetic()`'s default returns. The function-
58
+ // children wrapper bypasses the strict children-required typing.
59
+ Entrance: (props: Record<string, unknown>) => VNode | null,
60
+ childArray: VNode[],
61
+ ): VNode => {
62
+ // h() puts children in vnode.children (rest args). For mountComponent's
63
+ // merge to leave props.children alone, set it explicitly here.
64
+ return h(Entrance, {
65
+ show: () => true,
66
+ appear: true,
67
+ children: (() => childArray) as unknown as VNode[],
68
+ })
69
+ }
70
+
71
+ let containers: HTMLElement[] = []
72
+ afterEach(() => {
73
+ for (const c of containers) c.remove()
74
+ containers = []
75
+ })
76
+
77
+ describe('kinetic("div").stagger() — function-wrapped children survive render', () => {
78
+ it('iterates function-wrapped children correctly (no <undefined> tags)', () => {
79
+ const Entrance = kinetic('div')
80
+ .enter({ opacity: '0' })
81
+ .enterTo({ opacity: '1' })
82
+ .stagger({ interval: 20 })
83
+
84
+ const tree = buildEntranceWithFunctionChildren(Entrance as never, [
85
+ h('h1', { 'data-id': 'heading' }, 'Hello'),
86
+ h('p', { 'data-id': 'tagline' }, 'tagline'),
87
+ h('ul', { 'data-id': 'icons' }, h('li', null, 'icon-a')),
88
+ ])
89
+
90
+ const container = document.createElement('div')
91
+ document.body.appendChild(container)
92
+ containers.push(container)
93
+
94
+ const dispose = mount(tree as VNode, container)
95
+
96
+ // Children are rendered with proper element tags — NOT <undefined>
97
+ const heading = container.querySelector('[data-id="heading"]')
98
+ const tagline = container.querySelector('[data-id="tagline"]')
99
+ const icons = container.querySelector('[data-id="icons"]')
100
+
101
+ expect(
102
+ heading,
103
+ `heading missing — pre-fix the function-wrapped child got mounted as <undefined>. ` +
104
+ `container.innerHTML=${container.innerHTML.slice(0, 600)}`,
105
+ ).not.toBeNull()
106
+ expect(heading?.tagName).toBe('H1')
107
+ expect(heading?.textContent).toBe('Hello')
108
+
109
+ expect(tagline).not.toBeNull()
110
+ expect(tagline?.tagName).toBe('P')
111
+
112
+ expect(icons).not.toBeNull()
113
+ expect(icons?.tagName).toBe('UL')
114
+
115
+ // Sanity: no `<undefined>` tags should exist anywhere (pre-fix
116
+ // TransitionItem's cloneVNode(props.children, {ref}) on a function
117
+ // produced `{type: undefined, props: {ref}}` → mountElement called
118
+ // document.createElement(undefined) → `<undefined>` element).
119
+ expect(container.querySelector('undefined')).toBeNull()
120
+
121
+ dispose()
122
+ })
123
+
124
+ it('TransitionItem resolves function-wrapped children before cloneVNode (no <undefined> tag)', () => {
125
+ // Direct test for the SECOND fix-site — TransitionItem's
126
+ // `cloneVNode(props.children, {ref})`. Pre-fix, when the parent
127
+ // (StaggerRenderer/GroupRenderer) emits `<TransitionItem>{cloneVNode(c, {style})}</TransitionItem>`
128
+ // under the Pyreon vite-plugin, the compiler wraps the JSX child as
129
+ // `() => cloneVNode(c, {style})`. TransitionItem then receives
130
+ // `props.children = function`. `cloneVNode(function, {ref})` spreads
131
+ // the function (no own enumerable properties) → produces
132
+ // `{type: undefined, props: {ref}}` → mountElement creates literal
133
+ // `<undefined>` tag.
134
+ const childVNode = h('h1', { 'data-id': 'ti-heading' }, 'Hello')
135
+ const tree = h(TransitionItem, {
136
+ show: () => true,
137
+ appear: false,
138
+ timeout: 100,
139
+ enterStyle: { opacity: '0' },
140
+ enterToStyle: { opacity: '1' },
141
+ enterTransition: 'opacity 50ms ease',
142
+ // Function-wrapped children, mirroring the compiler's emit.
143
+ children: (() => childVNode) as unknown as VNode,
144
+ })
145
+
146
+ const container = document.createElement('div')
147
+ document.body.appendChild(container)
148
+ containers.push(container)
149
+
150
+ const dispose = mount(tree as VNode, container)
151
+
152
+ const heading = container.querySelector('[data-id="ti-heading"]')
153
+ expect(
154
+ heading,
155
+ `heading missing — pre-fix TransitionItem cloned the function, ` +
156
+ `producing <undefined>. container.innerHTML=${container.innerHTML.slice(0, 400)}`,
157
+ ).not.toBeNull()
158
+ expect(heading?.tagName).toBe('H1')
159
+ expect(heading?.textContent).toBe('Hello')
160
+ expect(container.querySelector('undefined')).toBeNull()
161
+
162
+ dispose()
163
+ })
164
+
165
+ it('iterates static-array children correctly (control — was always working)', () => {
166
+ const Entrance = kinetic('div')
167
+ .enter({ opacity: '0' })
168
+ .enterTo({ opacity: '1' })
169
+ .stagger({ interval: 20 })
170
+
171
+ // No compiler wrap — children as a plain array.
172
+ const tree = h(
173
+ Entrance,
174
+ { show: () => true, appear: true },
175
+ h('h1', { 'data-id': 'heading-static' }, 'Static'),
176
+ h('p', { 'data-id': 'tagline-static' }, 't'),
177
+ )
178
+
179
+ const container = document.createElement('div')
180
+ document.body.appendChild(container)
181
+ containers.push(container)
182
+
183
+ const dispose = mount(tree as VNode, container)
184
+
185
+ expect(container.querySelector('[data-id="heading-static"]')?.tagName).toBe('H1')
186
+ expect(container.querySelector('[data-id="tagline-static"]')?.tagName).toBe('P')
187
+ expect(container.querySelector('undefined')).toBeNull()
188
+
189
+ dispose()
190
+ })
191
+ })