@pyreon/kinetic 0.22.0 → 0.24.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/README.md CHANGED
@@ -1,149 +1,131 @@
1
1
  # @pyreon/kinetic
2
2
 
3
- CSS-first animation library for Pyreon. Enter/exit transitions, staggered animations, height collapse, and list reconciliation — all in ~3KB gzipped.
3
+ CSS-transition animation library enter/exit, stagger, collapse, list reconciliation, ~3KB.
4
4
 
5
- ## Why Kinetic?
6
-
7
- Most animation libraries run their own JavaScript animation loop on the main thread. Kinetic takes a different approach: it delegates all interpolation to the browser's CSS transition engine (compositor thread for `transform`/`opacity`), and only handles orchestration — mount/unmount lifecycle, stagger timing, height measurement, and list diffing.
8
-
9
- The result: GPU-composited 60/120 FPS animations with a 3.2KB footprint.
10
-
11
- ### How It Compares
12
-
13
- | Library | Gzipped | Engine | Enter/Exit | Stagger | List Recon. | Collapse | Reduced Motion |
14
- | ---------------------- | ---------- | ------------------- | ---------- | ------- | ----------- | -------- | -------------- |
15
- | **@pyreon/kinetic** | **3.2 KB** | CSS transitions | Yes | Yes | Yes | Yes | Yes |
16
- | Motion (framer-motion) | ~34 KB | JS (rAF + WAAPI) | Yes | Yes | Yes | Quirky | Yes |
17
- | @react-spring/web | ~16-24 KB | JS (spring physics) | Yes | Partial | Yes | Manual | Yes |
18
- | react-transition-group | ~5 KB | CSS classes | Yes | No | Yes | No | No |
19
- | AutoAnimate | ~2.5 KB | JS (FLIP) | Yes | No | Yes | No | Yes |
20
-
21
- **Key advantages:**
22
-
23
- - **10x smaller than Motion** for CSS-transition use cases
24
- - **CSS-first**: `transform`/`opacity` run on GPU compositor thread, not main thread
25
- - **Only library** combining CSS transitions + stagger + collapse + list reconciliation
26
- - **122 presets** available via `@pyreon/kinetic-presets`
5
+ `@pyreon/kinetic` delegates interpolation to the browser's CSS transition engine (GPU compositor thread for `transform` / `opacity`) and only handles orchestration: mount/unmount lifecycle, stagger timing, height measurement, and key-based list diffing. The result is 60/120fps animations with a 3.2KB footprint and four composable modes (transition, collapse, stagger, group) accessed through a chainable, immutable API. Pair with `@pyreon/kinetic-presets` for 120+ ready-made animations, or define your own via inline `.enter()` / `.enterTo()` styles or class-based transitions (Tailwind, CSS modules). Reduced-motion is detected automatically; SSR renders children in their hidden-state class so scroll-reveal patterns reach SEO crawlers.
27
6
 
28
7
  ## Install
29
8
 
30
9
  ```bash
31
- bun add @pyreon/kinetic
10
+ bun add @pyreon/kinetic @pyreon/core @pyreon/reactivity @pyreon/runtime-dom
32
11
  ```
33
12
 
34
- ## Quick Start
13
+ ## Quick start
35
14
 
36
- ```ts
15
+ ```tsx
37
16
  import { kinetic, fade, slideUp } from '@pyreon/kinetic'
38
17
  import { signal } from '@pyreon/reactivity'
39
18
 
40
- // Create animated components at module level
41
19
  const FadeDiv = kinetic('div').preset(fade)
42
20
  const SlideSection = kinetic('section').preset(slideUp)
43
21
 
44
- // Use with signals for reactive show/hide
45
22
  const show = signal(true)
46
-
47
- FadeDiv({ show: show(), children: 'Hello, world!' })
23
+ <FadeDiv show={show()}>Hello, world!</FadeDiv>
48
24
  ```
49
25
 
50
- ## API
26
+ ## How it compares
51
27
 
52
- ### `kinetic(tag)`
28
+ | Library | Gzipped | Engine | Enter/Exit | Stagger | List Recon. | Collapse | Reduced Motion |
29
+ | ---------------------- | ---------- | ------------------- | ---------- | ------- | ----------- | -------- | -------------- |
30
+ | **@pyreon/kinetic** | **3.2 KB** | CSS transitions | Yes | Yes | Yes | Yes | Yes |
31
+ | Motion (framer-motion) | ~34 KB | JS (rAF + WAAPI) | Yes | Yes | Yes | Quirky | Yes |
32
+ | @react-spring/web | ~16-24 KB | JS (spring physics) | Yes | Partial | Yes | Manual | Yes |
33
+ | react-transition-group | ~5 KB | CSS classes | Yes | No | Yes | No | No |
34
+ | AutoAnimate | ~2.5 KB | JS (FLIP) | Yes | No | Yes | No | Yes |
35
+
36
+ Key advantages: 10x smaller than Motion for CSS-transition use cases; only library combining CSS transitions + stagger + collapse + key-based list reconciliation; 120+ presets via `@pyreon/kinetic-presets`.
53
37
 
54
- Creates an animated component. `tag` can be any HTML element string or Pyreon component.
38
+ ## `kinetic(tag)` animated component factory
55
39
 
56
40
  ```ts
57
- kinetic('div') // HTML element
58
- kinetic('section') // Any HTML tag
59
- kinetic(MyComponent) // Pyreon component
41
+ kinetic('div') // HTML element string
42
+ kinetic('section')
43
+ kinetic(MyComponent) // Any Pyreon component
60
44
  ```
61
45
 
62
- Returns a renderable Pyreon component with chain methods attached. Default mode: **transition**.
46
+ Returns a renderable Pyreon component with chain methods. Default mode: **transition**.
63
47
 
64
- ### Chain Methods
48
+ ## Chain methods
65
49
 
66
- All methods return a new component (immutable). The tag generic flows through, preserving HTML attribute types.
50
+ Every method returns a new component (immutable). The tag generic flows through, preserving HTML attribute types.
67
51
 
68
52
  ```ts
69
- // Style-based animation config
70
- .enter(styles) // CSSProperties applied at enter start
71
- .enterTo(styles) // CSSProperties applied after first frame
72
- .enterTransition(value) // CSS transition string for enter
73
- .leave(styles) // CSSProperties applied at leave start
74
- .leaveTo(styles) // CSSProperties applied after first frame
75
- .leaveTransition(value) // CSS transition string for leave
76
-
77
- // Class-based animation config
53
+ // Inline style-based config
54
+ .enter(styles) // CSSProperties at enter start
55
+ .enterTo(styles) // CSSProperties after first frame
56
+ .enterTransition(value) // CSS transition string
57
+ .leave(styles) // CSSProperties at leave start
58
+ .leaveTo(styles) // CSSProperties after first frame
59
+ .leaveTransition(value)
60
+
61
+ // Class-based config (Tailwind / CSS modules friendly)
78
62
  .enterClass({ active?, from?, to? })
79
63
  .leaveClass({ active?, from?, to? })
80
64
 
81
- // Apply a preset (spreads style + class props)
65
+ // Preset (spreads style + class props)
82
66
  .preset(preset)
83
67
 
84
- // Behavior config
85
- .config(opts) // appear, unmount, timeout (+ mode-specific)
86
- .on(callbacks) // onEnter, onAfterEnter, onLeave, onAfterLeave
68
+ // Behaviour
69
+ .config({ appear, unmount, timeout, ... })
70
+ .on({ onEnter, onAfterEnter, onLeave, onAfterLeave })
87
71
 
88
72
  // Mode switches
89
- .collapse(opts?) // Height animation mode
90
- .stagger(opts?) // Staggered children mode
73
+ .collapse(opts?) // Height-animation mode
74
+ .stagger(opts?) // Staggered-children mode
91
75
  .group() // Key-based list reconciliation mode
92
76
  ```
93
77
 
94
- ### Four Modes
78
+ ## Four modes
95
79
 
96
- #### Transition (default)
80
+ ### Transition (default)
97
81
 
98
- Single element enter/leave with CSS transitions.
82
+ Single-element enter/leave with CSS transitions.
99
83
 
100
- ```ts
84
+ ```tsx
101
85
  const FadeDiv = kinetic('div').preset(fade)
102
-
103
- FadeDiv({ show: isOpen, children: 'Content' })
86
+ <FadeDiv show={isOpen}>Content</FadeDiv>
104
87
  ```
105
88
 
106
- #### Collapse
89
+ ### Collapse
107
90
 
108
91
  Height animation with `overflow: hidden`. Measures `scrollHeight` automatically.
109
92
 
110
- ```ts
93
+ ```tsx
111
94
  const Accordion = kinetic('div').collapse()
112
95
  const FancyAccordion = kinetic('section').collapse({
113
96
  transition: 'height 400ms cubic-bezier(0.4, 0, 0.2, 1)',
114
97
  })
115
98
 
116
- Accordion({ show: isExpanded, children: 'Expandable content' })
99
+ <Accordion show={isExpanded}>Expandable content</Accordion>
117
100
  ```
118
101
 
119
- #### Stagger
102
+ ### Stagger
120
103
 
121
104
  Staggered entrance/exit for child elements.
122
105
 
123
- ```ts
106
+ ```tsx
124
107
  const StaggerList = kinetic('ul').preset(slideUp).stagger({ interval: 75 })
125
108
 
126
- StaggerList({
127
- show: isVisible,
128
- children: [
129
- h('li', { key: '1' }, 'Item 1'),
130
- h('li', { key: '2' }, 'Item 2'),
131
- h('li', { key: '3' }, 'Item 3'),
132
- ],
133
- })
109
+ <StaggerList show={isVisible}>
110
+ <li key="1">Item 1</li>
111
+ <li key="2">Item 2</li>
112
+ <li key="3">Item 3</li>
113
+ </StaggerList>
134
114
  ```
135
115
 
136
- #### Group
116
+ ### Group
137
117
 
138
- Key-based enter/exit — adding a child triggers enter animation, removing triggers leave + unmount. No `show` prop.
118
+ Key-based enter/exit — adding a keyed child triggers enter; removing triggers leave + unmount. No `show` prop.
139
119
 
140
- ```ts
120
+ ```tsx
141
121
  const AnimatedList = kinetic('ul').preset(fade).group()
142
122
 
143
- AnimatedList({ children: items.map((item) => h('li', { key: item.id }, item.text)) })
123
+ <AnimatedList>
124
+ {items.map((item) => <li key={item.id}>{item.text}</li>)}
125
+ </AnimatedList>
144
126
  ```
145
127
 
146
- ### Inline Configuration
128
+ ## Inline configuration
147
129
 
148
130
  Build animations without presets:
149
131
 
@@ -157,7 +139,7 @@ const SlidePanel = kinetic('aside')
157
139
  .leaveTransition('all 200ms ease-in')
158
140
  ```
159
141
 
160
- ### Class-Based Transitions
142
+ ## Class-based transitions
161
143
 
162
144
  Works with Tailwind CSS, CSS modules, or any class-based approach:
163
145
 
@@ -167,79 +149,85 @@ const TailwindFade = kinetic('div')
167
149
  .leaveClass({ active: 'transition-opacity duration-200', from: 'opacity-100', to: 'opacity-0' })
168
150
  ```
169
151
 
170
- ### Lifecycle Callbacks
152
+ ## Lifecycle callbacks
171
153
 
172
- ```ts
173
- FadeDiv({
174
- show: isOpen,
175
- onEnter: () => console.log('entering'),
176
- onAfterEnter: () => console.log('entered'),
177
- onLeave: () => console.log('leaving'),
178
- onAfterLeave: () => console.log('left'),
179
- children: 'Content',
180
- })
154
+ ```tsx
155
+ <FadeDiv
156
+ show={isOpen}
157
+ onEnter={() => console.log('entering')}
158
+ onAfterEnter={() => console.log('entered')}
159
+ onLeave={() => console.log('leaving')}
160
+ onAfterLeave={() => console.log('left')}
161
+ >
162
+ Content
163
+ </FadeDiv>
181
164
  ```
182
165
 
183
- ### Accessibility
166
+ ## Composition with rocketstyle
184
167
 
185
- Kinetic automatically detects `prefers-reduced-motion: reduce`. When enabled, animations are skipped instantly — callbacks still fire, but no visual animation occurs. No configuration needed.
168
+ Kinetic and rocketstyle compose naturally:
186
169
 
187
- ## Built-in Presets
170
+ ```ts
171
+ import rocketstyle from '@pyreon/rocketstyle'
188
172
 
189
- Six presets are included in the core package:
173
+ const Button = rocketstyle()({ component: 'button', name: 'Button' })
174
+ .theme({ primaryColor: 'blue' })
190
175
 
191
- ```ts
192
- import { fade, scaleIn, slideUp, slideDown, slideLeft, slideRight } from '@pyreon/kinetic'
176
+ const AnimatedButton = kinetic(Button).preset(fade)
177
+ // Has BOTH rocketstyle dimension props AND kinetic show/lifecycle props
178
+ <AnimatedButton show={isVisible} state="primary" size="large">Click me</AnimatedButton>
193
179
  ```
194
180
 
195
- For 122 presets, factories, and composition utilities, install `@pyreon/kinetic-presets`.
181
+ ## Built-in presets
196
182
 
197
- ## Composition with Rocketstyle
183
+ Six presets are included in the core package: `fade`, `scaleIn`, `slideUp`, `slideDown`, `slideLeft`, `slideRight`. For 120+ presets, factories, and composition utilities, add `@pyreon/kinetic-presets`.
198
184
 
199
- Kinetic and rocketstyle compose naturally:
185
+ ## Low-level hooks
186
+
187
+ If you need transition state outside `kinetic()`:
200
188
 
201
189
  ```ts
202
- import rocketstyle from '@pyreon/rocketstyle'
190
+ import { useTransitionState, useAnimationEnd } from '@pyreon/kinetic'
203
191
 
204
- const Button = rocketstyle()({ component: 'button', name: 'Button' }).theme({
205
- primaryColor: 'blue',
206
- })
192
+ const state = useTransitionState({ show: () => isOpen() })
193
+ // state.stage() → 'enter' | 'enter-active' | 'enter-to' | 'leave' | 'leave-active' | 'leave-to' | 'idle'
194
+ ```
207
195
 
208
- const AnimatedButton = kinetic(Button).preset(fade)
196
+ ## Accessibility
209
197
 
210
- // Has both rocketstyle props AND kinetic props
211
- AnimatedButton({ show: isVisible, primary: true, size: 'large', children: 'Click me' })
212
- ```
198
+ Kinetic automatically detects `prefers-reduced-motion: reduce`. When enabled, animations are skipped instantly — callbacks still fire, but no visual animation occurs. No configuration needed.
213
199
 
214
200
  ## SSR / SSG
215
201
 
216
202
  `<Transition show={() => false}>` **always renders children in SSR**, with the hidden-state class inlined (`leaveTo` if defined, else `enterFrom`). This matches Framer Motion / react-transition-group / react-spring conventions: content is structural, animation is visual.
217
203
 
218
- This is load-bearing for the scroll-reveal pattern on SSG sites — `useIntersection` can't fire on the server, so `show` is false at SSR. Without structural rendering, the wrapped content would be absent from prerendered HTML (bad for SEO, social scrapers, no-JS users).
204
+ Load-bearing for scroll-reveal on SSG sites — `useIntersection` can't fire on the server, so `show` is false at SSR. Without structural rendering, the wrapped content would be absent from prerendered HTML (bad for SEO, social scrapers, no-JS users).
219
205
 
220
206
  ```tsx
221
207
  const RevealSection = kinetic('section')
222
- .enter('transition-all duration-700')
223
- .enterFrom('opacity-0 translate-y-8') // ← this IS the SSR hidden state
224
- .enterTo('opacity-100 translate-y-0')
208
+ .enterClass({ active: 'transition-all duration-700', from: 'opacity-0 translate-y-8', to: 'opacity-100 translate-y-0' })
225
209
 
226
- // In your route show is driven by useIntersection on the client.
227
- // At SSR: <section class="opacity-0 translate-y-8">…full content here…</section>
228
- // On client: when scrolled into view, show flips true, enter animation runs.
210
+ // SSR: <section class="opacity-0 translate-y-8">…full content…</section>
211
+ // Client: when scrolled into view, show flips true → enter animation runs
229
212
  <RevealSection show={isInView}>
230
213
  <h2>Work Experience</h2>
231
214
  <p>…content reaches SEO crawlers and social scrapers…</p>
232
215
  </RevealSection>
233
216
  ```
234
217
 
235
- **Trade-off**: for an initially-hidden Transition, `unmount: true` (the default) no longer triggers a true DOM removal after a later leave animation completes — the element stays in DOM with the leave-to class applied. **Initially-visible** Transitions (`show: () => true` at setup) keep the runtime-unmount semantic unchanged. If you need true unmount on a started-hidden element, drive mount/unmount yourself outside `<Transition>`.
218
+ **Trade-off**: for an initially-hidden Transition, `unmount: true` (the default) no longer triggers a true DOM removal after a later leave animation completes — the element stays in DOM with the leave-to class applied. **Initially-visible** Transitions keep the runtime-unmount semantic unchanged. If you need true unmount on a started-hidden element, drive mount/unmount yourself outside `<Transition>`.
219
+
220
+ ## Gotchas
221
+
222
+ - **Animations run on the GPU compositor thread** only when you animate `transform` / `opacity` / `filter`. Animating `width` / `height` / `top` / `left` falls back to the main thread and may jank.
223
+ - **Stagger `interval` is per-CHILD**, not total duration. Five children at 75ms = 375ms total stagger window.
224
+ - **Group mode requires keyed children.** Without `key=`, every render replaces every child and you get no animation. The compiler-suggested `<For each={items} by={i => i.id}>` is the idiomatic pattern.
225
+ - **SSR initial-hidden Transitions break true-unmount semantics.** See SSR / SSG section above — opt out by driving mount/unmount yourself.
226
+ - **Reduced-motion skips visuals but still fires callbacks.** Don't rely on the animation completing for state machine progression — use callbacks.
236
227
 
237
- ## Peer Dependencies
228
+ ## Documentation
238
229
 
239
- | Package | Version |
240
- | ------------------ | -------- |
241
- | @pyreon/core | >= 0.0.1 |
242
- | @pyreon/reactivity | >= 0.0.1 |
230
+ Full docs: [docs.pyreon.dev/docs/kinetic](https://docs.pyreon.dev/docs/kinetic) (or `docs/docs/kinetic.md` in this repo).
243
231
 
244
232
  ## License
245
233
 
package/lib/index.js CHANGED
@@ -290,6 +290,32 @@ const cloneVNode = (vnode, extraProps) => ({
290
290
  ...extraProps
291
291
  }
292
292
  });
293
+ /**
294
+ * Resolves a `children` value the Pyreon compiler may have wrapped in a
295
+ * deferred accessor.
296
+ *
297
+ * **Why:** the compiler's prop-inlining pass rewrites `<Comp>{children}</Comp>`
298
+ * to `Comp({ ..., children: () => <inlined-expression> })` whenever
299
+ * `children` is a local `const` derived from a getter-shaped binding
300
+ * (`const children = childHolder.children` after `splitProps`). DOM-side
301
+ * consumers route through `mountChild` which already treats function
302
+ * children as reactive accessors, so the wrap is invisible there. Kinetic's
303
+ * Stagger/Group/Transition/Collapse renderers iterate `children` directly
304
+ * at the VNode level (to build per-child `TransitionItem`s), so a wrapped
305
+ * function landed in `Array.isArray(children) ? children : [children]` as
306
+ * `[function]` → `.filter(isVNode)` → `[]` → the rendered `<div>` had zero
307
+ * children → SSR content vanished post-hydration. Reproducer:
308
+ * `examples/bokisch.com`'s Intro section with `kinetic('div').stagger()`
309
+ * + `appear` + `show={() => true}` + component children → SSG HTML had
310
+ * `<h1>Hello</h1>`, post-hydrate the entire subtree was replaced by
311
+ * `<!--pyreon-->` markers.
312
+ *
313
+ * Kinetic deliberately snapshots children at render time (animation state
314
+ * is per-item, built once) — it does NOT observe children changes after
315
+ * the initial render. Eagerly unwrapping the function matches that
316
+ * contract; no reactivity is lost.
317
+ */
318
+ const resolveChildren = (children) => typeof children === "function" ? children() : children;
293
319
 
294
320
  //#endregion
295
321
  //#region src/kinetic/TransitionItem.tsx
@@ -338,6 +364,7 @@ const applyReducedMotion$1 = (stage, callbacks, complete) => {
338
364
  * Uses cloneVNode to inject ref onto the child — the child must accept ref.
339
365
  */
340
366
  const TransitionItem = (props) => {
367
+ const child = resolveChildren(props.children);
341
368
  const appear = props.appear ?? false;
342
369
  const unmount = props.unmount ?? true;
343
370
  const timeout = props.timeout ?? 5e3;
@@ -347,7 +374,7 @@ const TransitionItem = (props) => {
347
374
  appear
348
375
  });
349
376
  const elementRef = createRef();
350
- const mergedRef = mergeRefs(elementRef, stateRef, props.children.props?.ref);
377
+ const mergedRef = mergeRefs(elementRef, stateRef, (child?.props)?.ref);
351
378
  const callbacks = {
352
379
  onEnter: props.onEnter,
353
380
  onAfterEnter: props.onAfterEnter,
@@ -402,22 +429,22 @@ const TransitionItem = (props) => {
402
429
  }, { immediate: true });
403
430
  if (props.show()) return /* @__PURE__ */ jsx(Show, {
404
431
  when: shouldMount,
405
- fallback: unmount ? null : cloneVNode(props.children, {
432
+ fallback: unmount ? null : cloneVNode(child, {
406
433
  ref: mergedRef,
407
- style: mergeStyles(props.children.props?.style, { display: "none" })
434
+ style: mergeStyles((child?.props)?.style, { display: "none" })
408
435
  }),
409
- children: cloneVNode(props.children, { ref: mergedRef })
436
+ children: cloneVNode(child, { ref: mergedRef })
410
437
  });
411
438
  const hiddenClass = props.leaveTo ?? props.enterFrom;
412
439
  const hiddenStyle = props.leaveToStyle ?? props.enterStyle;
413
- const childProps = props.children.props ?? {};
440
+ const childProps = child?.props ?? {};
414
441
  const childClass = childProps.class;
415
442
  const mergedClass = hiddenClass ? cx([childClass, hiddenClass]) : void 0;
416
443
  const mergedStyle = mergeStyles(childProps.style, hiddenStyle);
417
444
  const extra = { ref: mergedRef };
418
445
  if (mergedClass !== void 0) extra.class = mergedClass;
419
446
  if (mergedStyle !== void 0) extra.style = mergedStyle;
420
- return cloneVNode(props.children, extra);
447
+ return cloneVNode(child, extra);
421
448
  };
422
449
 
423
450
  //#endregion
@@ -512,7 +539,8 @@ const StaggerRenderer = ({ config, htmlProps, show, appear, timeout, interval, r
512
539
  const effectiveTimeout = timeout ?? config.timeout ?? 5e3;
513
540
  const effectiveInterval = interval ?? config.interval ?? 50;
514
541
  const effectiveReverseLeave = reverseLeave ?? config.reverseLeave ?? false;
515
- const childArray = (Array.isArray(children) ? children : [children]).filter(isVNode);
542
+ const resolved = resolveChildren(children);
543
+ const childArray = (Array.isArray(resolved) ? resolved : [resolved]).filter(isVNode);
516
544
  const count = childArray.length;
517
545
  const staggeredChildren = childArray.map((child, index) => {
518
546
  const staggerIndex = !show() && effectiveReverseLeave ? count - 1 - index : index;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/kinetic",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "CSS-transition-based animation components for Pyreon",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,21 +42,21 @@
42
42
  "typecheck": "tsc --noEmit"
43
43
  },
44
44
  "devDependencies": {
45
- "@pyreon/core": "^0.22.0",
46
- "@pyreon/reactivity": "^0.22.0",
47
- "@pyreon/runtime-dom": "^0.22.0",
48
- "@pyreon/runtime-server": "^0.22.0",
49
- "@pyreon/test-utils": "^0.13.9",
50
- "@pyreon/typescript": "^0.22.0",
45
+ "@pyreon/core": "^0.24.0",
46
+ "@pyreon/reactivity": "^0.24.0",
47
+ "@pyreon/runtime-dom": "^0.24.0",
48
+ "@pyreon/runtime-server": "^0.24.0",
49
+ "@pyreon/test-utils": "^0.13.11",
50
+ "@pyreon/typescript": "^0.24.0",
51
51
  "@vitest/browser-playwright": "^4.1.4",
52
- "@vitus-labs/tools-rolldown": "^2.3.0"
52
+ "@vitus-labs/tools-rolldown": "^2.4.0"
53
53
  },
54
54
  "engines": {
55
55
  "node": ">= 22"
56
56
  },
57
57
  "dependencies": {
58
- "@pyreon/core": "^0.22.0",
59
- "@pyreon/reactivity": "^0.22.0",
60
- "@pyreon/runtime-dom": "^0.22.0"
58
+ "@pyreon/core": "^0.24.0",
59
+ "@pyreon/reactivity": "^0.24.0",
60
+ "@pyreon/runtime-dom": "^0.24.0"
61
61
  }
62
62
  }
package/src/Stagger.tsx CHANGED
@@ -2,7 +2,7 @@ import type { VNode } from '@pyreon/core'
2
2
  import { splitProps } from '@pyreon/core'
3
3
  import Transition from './Transition'
4
4
  import type { CSSProperties, StaggerProps } from './types'
5
- import { cloneVNode } from './utils'
5
+ import { cloneVNode, resolveChildren } from './utils'
6
6
 
7
7
  const isVNode = (child: unknown): child is VNode =>
8
8
  child != null && typeof child === 'object' && 'type' in (child as object)
@@ -22,7 +22,12 @@ const Stagger = (props: StaggerProps): VNode | null => {
22
22
  const appear = own.appear ?? false
23
23
  const timeout = own.timeout ?? 5000
24
24
 
25
- const childArray = (Array.isArray(own.children) ? own.children : [own.children]).filter(isVNode)
25
+ // Unwrap the compiler's `() => x` accessor wrap — see `resolveChildren`
26
+ // jsdoc. PR #731 fixed this on `StaggerRenderer` (the internal kinetic-
27
+ // mode renderer); this is the parallel fix for the top-level `<Stagger>`
28
+ // component, which has the same iteration shape and the same bug.
29
+ const resolved = resolveChildren(own.children)
30
+ const childArray = (Array.isArray(resolved) ? resolved : [resolved]).filter(isVNode)
26
31
  const count = childArray.length
27
32
 
28
33
  return (
@@ -5,7 +5,15 @@ import type { ClassTransitionProps, StyleTransitionProps, TransitionProps } from
5
5
  import useAnimationEnd from './useAnimationEnd'
6
6
  import { useReducedMotion } from './useReducedMotion'
7
7
  import useTransitionState from './useTransitionState'
8
- import { addClasses, cloneVNode, mergeRefs, mergeStyles, nextFrame, removeClasses } from './utils'
8
+ import {
9
+ addClasses,
10
+ cloneVNode,
11
+ mergeRefs,
12
+ mergeStyles,
13
+ nextFrame,
14
+ removeClasses,
15
+ resolveChildren,
16
+ } from './utils'
9
17
 
10
18
  const applyEnter = (
11
19
  el: HTMLElement,
@@ -109,8 +117,14 @@ const Transition = (props: TransitionProps): VNode | null => {
109
117
  appear,
110
118
  })
111
119
 
120
+ // Unwrap the compiler's `() => x` accessor wrap — see `resolveChildren`
121
+ // jsdoc. Parallel to `TransitionItem`'s fix (PR #731). Without this,
122
+ // `props.children.props` reads `function.props` (undefined), the merged
123
+ // ref is missing the child's own ref, and the downstream `cloneVNode`
124
+ // calls produce `{type: undefined}` → `<undefined>` DOM tags.
125
+ const child = resolveChildren(props.children) as VNode
112
126
  const elementRef = createRef<HTMLElement>()
113
- const childProps = (props.children.props ?? {}) as Record<string, unknown>
127
+ const childProps = (child?.props ?? {}) as Record<string, unknown>
114
128
  const mergedRef = mergeRefs(
115
129
  elementRef,
116
130
  stateRef,
@@ -198,7 +212,7 @@ const Transition = (props: TransitionProps): VNode | null => {
198
212
  fallback={
199
213
  unmount
200
214
  ? null
201
- : cloneVNode(props.children, {
215
+ : cloneVNode(child, {
202
216
  ref: mergedRef,
203
217
  style: mergeStyles(
204
218
  childProps.style as Record<string, string | number | undefined> | undefined,
@@ -207,7 +221,7 @@ const Transition = (props: TransitionProps): VNode | null => {
207
221
  })
208
222
  }
209
223
  >
210
- {cloneVNode(props.children, { ref: mergedRef })}
224
+ {cloneVNode(child, { ref: mergedRef })}
211
225
  </Show>
212
226
  )
213
227
  }
@@ -260,7 +274,7 @@ const Transition = (props: TransitionProps): VNode | null => {
260
274
  if (mergedClass !== undefined) extra.class = mergedClass
261
275
  if (mergedStyle !== undefined) extra.style = mergedStyle
262
276
 
263
- return cloneVNode(props.children, extra)
277
+ return cloneVNode(child, extra)
264
278
  }
265
279
 
266
280
  export default Transition
@@ -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
+ })
@@ -0,0 +1,141 @@
1
+ /** @jsxImportSource @pyreon/core */
2
+ /**
3
+ * Regression: PR #731 fixed the kinetic-mode renderers (StaggerRenderer +
4
+ * TransitionItem under `src/kinetic/`) but missed the parallel TOP-LEVEL
5
+ * `<Transition>` and `<Stagger>` components in `src/Transition.tsx` and
6
+ * `src/Stagger.tsx`. They have the SAME iteration + cloneVNode shape and
7
+ * the SAME bug when the Pyreon compiler wraps the children prop in
8
+ * `() => x` (the prop-inlining pass).
9
+ *
10
+ * The Pyreon vite-plugin auto-wraps `<Comp>{x}</Comp>` JSX child
11
+ * expressions in `() => x` for stable prop-derived references; downstream
12
+ * libraries that iterate `props.children` directly at the VNode level or
13
+ * `cloneVNode` them silently break — the function spread produces
14
+ * `{type: undefined}` → `<undefined>` DOM tags. PR #732 added the
15
+ * compiler carve-out for stable references; library-side `resolveChildren`
16
+ * is still needed for the CallExpression-inside-JSX-child shape that the
17
+ * compiler (correctly) doesn't optimize.
18
+ *
19
+ * Bisect-verified: reverting the `resolveChildren` call in `Stagger.tsx`
20
+ * fails the Stagger spec (no children rendered); reverting in
21
+ * `Transition.tsx` fails the Transition spec (`<undefined>` tag rendered
22
+ * instead of the cloned child).
23
+ */
24
+ import type { VNode } from '@pyreon/core'
25
+ import { h } from '@pyreon/core'
26
+ import { mount } from '@pyreon/runtime-dom'
27
+ import { afterEach, describe, expect, it } from 'vitest'
28
+ import Stagger from '../Stagger'
29
+ import Transition from '../Transition'
30
+
31
+ let containers: HTMLElement[] = []
32
+ afterEach(() => {
33
+ for (const c of containers) c.remove()
34
+ containers = []
35
+ })
36
+
37
+ describe('top-level <Stagger> — function-wrapped children survive render', () => {
38
+ it('iterates function-wrapped children correctly (no empty render)', () => {
39
+ const childArray: VNode[] = [
40
+ h('h1', { 'data-id': 'st-h1' }, 'Hello'),
41
+ h('p', { 'data-id': 'st-p' }, 'tagline'),
42
+ h('ul', { 'data-id': 'st-ul' }, h('li', null, 'a')),
43
+ ]
44
+
45
+ const tree = h(Stagger, {
46
+ show: () => true,
47
+ appear: true,
48
+ interval: 20,
49
+ // Compiler-emitted shape: children is a function returning the array.
50
+ children: (() => childArray) as unknown as VNode[],
51
+ })
52
+
53
+ const container = document.createElement('div')
54
+ document.body.appendChild(container)
55
+ containers.push(container)
56
+
57
+ const dispose = mount(tree as VNode, container)
58
+
59
+ const h1 = container.querySelector('[data-id="st-h1"]')
60
+ const p = container.querySelector('[data-id="st-p"]')
61
+ const ul = container.querySelector('[data-id="st-ul"]')
62
+
63
+ expect(
64
+ h1,
65
+ `Stagger collapsed when children is a function — html=${container.innerHTML.slice(0, 400)}`,
66
+ ).not.toBeNull()
67
+ expect(h1?.tagName).toBe('H1')
68
+ expect(h1?.textContent).toBe('Hello')
69
+ expect(p?.tagName).toBe('P')
70
+ expect(ul?.tagName).toBe('UL')
71
+ expect(container.querySelector('undefined')).toBeNull()
72
+
73
+ dispose()
74
+ })
75
+
76
+ it('static-array children control — was always working', () => {
77
+ const tree = h(
78
+ Stagger,
79
+ { show: () => true, appear: true, interval: 20 },
80
+ h('h1', { 'data-id': 'st-static' }, 'Static'),
81
+ h('p', { 'data-id': 'st-static-p' }, 't'),
82
+ )
83
+
84
+ const container = document.createElement('div')
85
+ document.body.appendChild(container)
86
+ containers.push(container)
87
+ const dispose = mount(tree as VNode, container)
88
+
89
+ expect(container.querySelector('[data-id="st-static"]')?.tagName).toBe('H1')
90
+ expect(container.querySelector('[data-id="st-static-p"]')?.tagName).toBe('P')
91
+
92
+ dispose()
93
+ })
94
+ })
95
+
96
+ describe('top-level <Transition> — function-wrapped children survive render', () => {
97
+ it('resolves function-wrapped children before cloneVNode (no <undefined> tag)', () => {
98
+ const childVNode = h('h1', { 'data-id': 'tn-h1' }, 'Hello')
99
+
100
+ const tree = h(Transition, {
101
+ show: () => true,
102
+ appear: false,
103
+ // Compiler-emitted shape.
104
+ children: (() => childVNode) as unknown as VNode,
105
+ })
106
+
107
+ const container = document.createElement('div')
108
+ document.body.appendChild(container)
109
+ containers.push(container)
110
+
111
+ const dispose = mount(tree as VNode, container)
112
+
113
+ const h1 = container.querySelector('[data-id="tn-h1"]')
114
+ expect(
115
+ h1,
116
+ `Transition produced <undefined> — html=${container.innerHTML.slice(0, 400)}`,
117
+ ).not.toBeNull()
118
+ expect(h1?.tagName).toBe('H1')
119
+ expect(h1?.textContent).toBe('Hello')
120
+ expect(container.querySelector('undefined')).toBeNull()
121
+
122
+ dispose()
123
+ })
124
+
125
+ it('static-VNode child control — was always working', () => {
126
+ const tree = h(
127
+ Transition,
128
+ { show: () => true, appear: false },
129
+ h('h1', { 'data-id': 'tn-static' }, 'Static'),
130
+ )
131
+
132
+ const container = document.createElement('div')
133
+ document.body.appendChild(container)
134
+ containers.push(container)
135
+ const dispose = mount(tree as VNode, container)
136
+
137
+ expect(container.querySelector('[data-id="tn-static"]')?.tagName).toBe('H1')
138
+
139
+ dispose()
140
+ })
141
+ })
@@ -1,7 +1,7 @@
1
1
  import type { VNode } from '@pyreon/core'
2
2
  import { h } from '@pyreon/core'
3
3
  import type { CSSProperties, TransitionCallbacks } from '../types'
4
- import { cloneVNode } from '../utils'
4
+ import { cloneVNode, resolveChildren } from '../utils'
5
5
  import TransitionItem from './TransitionItem'
6
6
  import type { KineticConfig } from './types'
7
7
 
@@ -41,7 +41,9 @@ const StaggerRenderer = ({
41
41
  const effectiveInterval = interval ?? config.interval ?? 50
42
42
  const effectiveReverseLeave = reverseLeave ?? config.reverseLeave ?? false
43
43
 
44
- const childArray = (Array.isArray(children) ? children : [children]).filter(isVNode)
44
+ // Unwrap compiler-emitted accessor wrap see `resolveChildren` jsdoc.
45
+ const resolved = resolveChildren(children)
46
+ const childArray = (Array.isArray(resolved) ? resolved : [resolved]).filter(isVNode)
45
47
  const count = childArray.length
46
48
 
47
49
  const staggeredChildren = childArray.map((child, index) => {
@@ -5,7 +5,15 @@ import type { ClassTransitionProps, StyleTransitionProps, TransitionCallbacks }
5
5
  import useAnimationEnd from '../useAnimationEnd'
6
6
  import { useReducedMotion } from '../useReducedMotion'
7
7
  import useTransitionState from '../useTransitionState'
8
- import { addClasses, cloneVNode, mergeRefs, mergeStyles, nextFrame, removeClasses } from '../utils'
8
+ import {
9
+ addClasses,
10
+ cloneVNode,
11
+ mergeRefs,
12
+ mergeStyles,
13
+ nextFrame,
14
+ removeClasses,
15
+ resolveChildren,
16
+ } from '../utils'
9
17
 
10
18
  type TransitionItemProps = ClassTransitionProps &
11
19
  StyleTransitionProps &
@@ -78,6 +86,18 @@ const applyReducedMotion = (
78
86
  * Uses cloneVNode to inject ref onto the child — the child must accept ref.
79
87
  */
80
88
  const TransitionItem = (props: TransitionItemProps): VNode | null => {
89
+ // The Pyreon compiler wraps `{cloneVNode(child, {...})}` JSX child
90
+ // expressions in StaggerRenderer/GroupRenderer as `() => cloneVNode(...)`
91
+ // (prop-inlining for reactivity — see `resolveChildren` jsdoc). At this
92
+ // boundary `props.children` therefore arrives as a FUNCTION instead of a
93
+ // VNode. cloneVNode-on-a-function silently produces `{type: undefined,
94
+ // props: {ref: ...}}` (spreading a function yields no own properties
95
+ // because functions have none enumerable), which mountElement renders
96
+ // as a literal `<undefined>` tag in the DOM — the SSG'd `<h1>Hello</h1>`
97
+ // becomes an empty `<undefined></undefined>` post-hydrate (reproducer:
98
+ // bokisch.com Intro section). Resolve eagerly so the entire body below
99
+ // can treat `child` as a static VNode.
100
+ const child = resolveChildren(props.children) as VNode
81
101
  const appear = props.appear ?? false
82
102
  const unmount = props.unmount ?? true
83
103
  const timeout = props.timeout ?? 5000
@@ -91,7 +111,7 @@ const TransitionItem = (props: TransitionItemProps): VNode | null => {
91
111
  const mergedRef = mergeRefs(
92
112
  elementRef,
93
113
  stateRef,
94
- (props.children.props as Record<string, unknown>)?.ref as
114
+ (child?.props as Record<string, unknown>)?.ref as
95
115
  | ((el: HTMLElement | null) => void)
96
116
  | undefined,
97
117
  )
@@ -182,10 +202,10 @@ const TransitionItem = (props: TransitionItemProps): VNode | null => {
182
202
  fallback={
183
203
  unmount
184
204
  ? null
185
- : cloneVNode(props.children, {
205
+ : cloneVNode(child, {
186
206
  ref: mergedRef,
187
207
  style: mergeStyles(
188
- (props.children.props as Record<string, unknown>)?.style as
208
+ (child?.props as Record<string, unknown>)?.style as
189
209
  | Record<string, string | number | undefined>
190
210
  | undefined,
191
211
  { display: 'none' },
@@ -193,7 +213,7 @@ const TransitionItem = (props: TransitionItemProps): VNode | null => {
193
213
  })
194
214
  }
195
215
  >
196
- {cloneVNode(props.children, { ref: mergedRef })}
216
+ {cloneVNode(child, { ref: mergedRef })}
197
217
  </Show>
198
218
  )
199
219
  }
@@ -210,7 +230,7 @@ const TransitionItem = (props: TransitionItemProps): VNode | null => {
210
230
  // Initially-visible items keep the unmount semantic.
211
231
  const hiddenClass = props.leaveTo ?? props.enterFrom
212
232
  const hiddenStyle = props.leaveToStyle ?? props.enterStyle
213
- const childProps = (props.children.props ?? {}) as Record<string, unknown>
233
+ const childProps = (child?.props ?? {}) as Record<string, unknown>
214
234
  const childClass = childProps.class
215
235
  const mergedClass = hiddenClass
216
236
  ? cx([childClass as Parameters<typeof cx>[0], hiddenClass])
@@ -224,7 +244,7 @@ const TransitionItem = (props: TransitionItemProps): VNode | null => {
224
244
  if (mergedClass !== undefined) extra.class = mergedClass
225
245
  if (mergedStyle !== undefined) extra.style = mergedStyle
226
246
 
227
- return cloneVNode(props.children, extra)
247
+ return cloneVNode(child, extra)
228
248
  }
229
249
 
230
250
  export default TransitionItem
package/src/utils.ts CHANGED
@@ -83,3 +83,31 @@ export const cloneVNode = (vnode: VNode, extraProps: Record<string, unknown>): V
83
83
  ...vnode,
84
84
  props: { ...vnode.props, ...extraProps },
85
85
  })
86
+
87
+ /**
88
+ * Resolves a `children` value the Pyreon compiler may have wrapped in a
89
+ * deferred accessor.
90
+ *
91
+ * **Why:** the compiler's prop-inlining pass rewrites `<Comp>{children}</Comp>`
92
+ * to `Comp({ ..., children: () => <inlined-expression> })` whenever
93
+ * `children` is a local `const` derived from a getter-shaped binding
94
+ * (`const children = childHolder.children` after `splitProps`). DOM-side
95
+ * consumers route through `mountChild` which already treats function
96
+ * children as reactive accessors, so the wrap is invisible there. Kinetic's
97
+ * Stagger/Group/Transition/Collapse renderers iterate `children` directly
98
+ * at the VNode level (to build per-child `TransitionItem`s), so a wrapped
99
+ * function landed in `Array.isArray(children) ? children : [children]` as
100
+ * `[function]` → `.filter(isVNode)` → `[]` → the rendered `<div>` had zero
101
+ * children → SSR content vanished post-hydration. Reproducer:
102
+ * `examples/bokisch.com`'s Intro section with `kinetic('div').stagger()`
103
+ * + `appear` + `show={() => true}` + component children → SSG HTML had
104
+ * `<h1>Hello</h1>`, post-hydrate the entire subtree was replaced by
105
+ * `<!--pyreon-->` markers.
106
+ *
107
+ * Kinetic deliberately snapshots children at render time (animation state
108
+ * is per-item, built once) — it does NOT observe children changes after
109
+ * the initial render. Eagerly unwrapping the function matches that
110
+ * contract; no reactivity is lost.
111
+ */
112
+ export const resolveChildren = <T>(children: T | (() => T)): T =>
113
+ (typeof children === 'function' ? (children as () => T)() : children) as T