@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.
@@ -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
+ })
@@ -166,6 +166,39 @@ const CollapseRenderer = ({
166
166
  ...(stage() === 'hidden' ? { height: '0px' } : stage() === 'entered' ? { height: 'auto' } : {}),
167
167
  }
168
168
 
169
+ // Initially-visible Collapses keep the original Show-gated inner content,
170
+ // preserving the runtime-unmount semantic that frees the inner subtree
171
+ // when the collapse is closed long-term. The SSR bug fires only when
172
+ // `show: () => false` at setup — the outer wrapper renders (with
173
+ // `height: 0; overflow: hidden`) but its children are stripped by the
174
+ // inner `<Show when={false}>` → empty wrapper in the prerendered HTML.
175
+ // Bad for SEO / social scrapers / accessibility / no-JS.
176
+ //
177
+ // Mirrors the fix shape applied to `<Transition>` (PR #717), the
178
+ // `TransitionRenderer` and `TransitionItem` (this PR). Ecosystem norm:
179
+ // content is structural, animation is visual.
180
+ //
181
+ // For initially-hidden Collapses, the inner content always renders —
182
+ // the outer wrapper's `height: 0px; overflow: hidden` already provides
183
+ // the visual hiding (genuinely layout-safe — no flex slot collapse;
184
+ // the outer wrapper participates in flex as a 0-height box, which is
185
+ // the standard CSS collapse behavior). When `show` flips true, the
186
+ // existing `watch(stage)` measures `content.scrollHeight` and animates
187
+ // height from 0 → that value — no change to the animation path.
188
+ //
189
+ // Trade-off: for initially-hidden Collapses, the inner subtree is
190
+ // ALWAYS mounted (never unmounted after a later close). Initially-
191
+ // visible Collapses keep the unmount behavior. Matches the trade-off
192
+ // documented across the other three kinetic renderers.
193
+ const wasInitiallyShown = show()
194
+ const innerContent = wasInitiallyShown ? (
195
+ <Show when={shouldRender}>
196
+ <div ref={contentRef}>{children}</div>
197
+ </Show>
198
+ ) : (
199
+ <div ref={contentRef}>{children}</div>
200
+ )
201
+
169
202
  // mergeProps (descriptor-preserving) instead of `{ ...htmlProps }` —
170
203
  // every non-style HTML attr keeps its reactive getter; ref + the
171
204
  // collapse-controlled style come last so they win (mergeProps is
@@ -176,9 +209,7 @@ const CollapseRenderer = ({
176
209
  return h(
177
210
  config.tag,
178
211
  mergeProps(htmlProps, { ref: wrapperRef, style: wrapperStyle }),
179
- <Show when={shouldRender}>
180
- <div ref={contentRef}>{children}</div>
181
- </Show>,
212
+ innerContent,
182
213
  )
183
214
  }
184
215
 
@@ -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) => {
@@ -1,11 +1,19 @@
1
1
  import type { VNode } from '@pyreon/core'
2
- import { createRef, Show } from '@pyreon/core'
2
+ import { createRef, cx, Show } from '@pyreon/core'
3
3
  import { watch } from '@pyreon/reactivity'
4
4
  import type { ClassTransitionProps, StyleTransitionProps, TransitionCallbacks } from '../types'
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 &
@@ -19,6 +27,14 @@ type TransitionItemProps = ClassTransitionProps &
19
27
  }
20
28
 
21
29
  const applyEnter = (el: HTMLElement, config: ClassTransitionProps & StyleTransitionProps) => {
30
+ // Symmetric to applyLeave: clear residual leave-cycle classes — including
31
+ // the `leaveTo`/`enterFrom` class the SSR/initially-hidden render path
32
+ // inlines (see the `wasInitiallyShown` branch below). Without this, the
33
+ // SSR-baked hidden class would compete with `enterTo`'s CSS rules.
34
+ removeClasses(el, config.leave)
35
+ removeClasses(el, config.leaveFrom)
36
+ removeClasses(el, config.leaveTo)
37
+
22
38
  addClasses(el, config.enter)
23
39
  addClasses(el, config.enterFrom)
24
40
  if (config.enterStyle) Object.assign(el.style, config.enterStyle)
@@ -70,6 +86,18 @@ const applyReducedMotion = (
70
86
  * Uses cloneVNode to inject ref onto the child — the child must accept ref.
71
87
  */
72
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
73
101
  const appear = props.appear ?? false
74
102
  const unmount = props.unmount ?? true
75
103
  const timeout = props.timeout ?? 5000
@@ -83,7 +111,7 @@ const TransitionItem = (props: TransitionItemProps): VNode | null => {
83
111
  const mergedRef = mergeRefs(
84
112
  elementRef,
85
113
  stateRef,
86
- (props.children.props as Record<string, unknown>)?.ref as
114
+ (child?.props as Record<string, unknown>)?.ref as
87
115
  | ((el: HTMLElement | null) => void)
88
116
  | undefined,
89
117
  )
@@ -155,26 +183,68 @@ const TransitionItem = (props: TransitionItemProps): VNode | null => {
155
183
  { immediate: true },
156
184
  )
157
185
 
158
- return (
159
- <Show
160
- when={shouldMount}
161
- fallback={
162
- unmount
163
- ? null
164
- : cloneVNode(props.children, {
165
- ref: mergedRef,
166
- style: mergeStyles(
167
- (props.children.props as Record<string, unknown>)?.style as
168
- | Record<string, string | number | undefined>
169
- | undefined,
170
- { display: 'none' },
171
- ),
172
- })
173
- }
174
- >
175
- {cloneVNode(props.children, { ref: mergedRef })}
176
- </Show>
186
+ // Initially-visible items keep the original Show-gated mount, preserving
187
+ // the documented runtime-unmount semantic for visible→hidden. The SSR
188
+ // bug (children dropped from prerendered HTML) only fires for the
189
+ // initially-HIDDEN case below, where `<Show when={false}>` renders `null`
190
+ // on the server. For Stagger/Group usage at SSR (when the parent's
191
+ // `show: () => false`), each per-item TransitionItem hit this and
192
+ // dropped its child — full list missing from prerendered HTML.
193
+ //
194
+ // Mirrors the fix in `<Transition>` (PR #717) and `TransitionRenderer`
195
+ // (same PR as this). Ecosystem norm: content is structural, animation
196
+ // is visual.
197
+ const wasInitiallyShown = props.show()
198
+ if (wasInitiallyShown) {
199
+ return (
200
+ <Show
201
+ when={shouldMount}
202
+ fallback={
203
+ unmount
204
+ ? null
205
+ : cloneVNode(child, {
206
+ ref: mergedRef,
207
+ style: mergeStyles(
208
+ (child?.props as Record<string, unknown>)?.style as
209
+ | Record<string, string | number | undefined>
210
+ | undefined,
211
+ { display: 'none' },
212
+ ),
213
+ })
214
+ }
215
+ >
216
+ {cloneVNode(child, { ref: mergedRef })}
217
+ </Show>
218
+ )
219
+ }
220
+
221
+ // Initially-hidden path — always emit the child with hidden-state class +
222
+ // style inlined. `leaveTo`/`leaveToStyle` (explicit hidden-end state)
223
+ // wins; falls back to `enterFrom`/`enterStyle` (pre-enter state — covers
224
+ // both class-based scroll-reveal AND the preset path, where
225
+ // `@pyreon/kinetic-presets` factories populate `enterStyle` as the
226
+ // hidden state but may not set `leaveToStyle`).
227
+ //
228
+ // Trade-off: for an initially-hidden item, `unmount: true` no longer
229
+ // triggers a true DOM removal after a later leave animation completes.
230
+ // Initially-visible items keep the unmount semantic.
231
+ const hiddenClass = props.leaveTo ?? props.enterFrom
232
+ const hiddenStyle = props.leaveToStyle ?? props.enterStyle
233
+ const childProps = (child?.props ?? {}) as Record<string, unknown>
234
+ const childClass = childProps.class
235
+ const mergedClass = hiddenClass
236
+ ? cx([childClass as Parameters<typeof cx>[0], hiddenClass])
237
+ : undefined
238
+ const mergedStyle = mergeStyles(
239
+ childProps.style as Record<string, string | number | undefined> | undefined,
240
+ hiddenStyle,
177
241
  )
242
+
243
+ const extra: Record<string, unknown> = { ref: mergedRef }
244
+ if (mergedClass !== undefined) extra.class = mergedClass
245
+ if (mergedStyle !== undefined) extra.style = mergedStyle
246
+
247
+ return cloneVNode(child, extra)
178
248
  }
179
249
 
180
250
  export default TransitionItem
@@ -1,5 +1,5 @@
1
1
  import type { VNode } from '@pyreon/core'
2
- import { createRef, h, mergeProps, Show } from '@pyreon/core'
2
+ import { createRef, cx, h, mergeProps, Show } from '@pyreon/core'
3
3
  import { watch } from '@pyreon/reactivity'
4
4
  import type { CSSProperties, TransitionCallbacks } from '../types'
5
5
  import useAnimationEnd from '../useAnimationEnd'
@@ -20,6 +20,15 @@ type TransitionRendererProps = {
20
20
  }
21
21
 
22
22
  const applyEnter = (el: HTMLElement, config: KineticConfig) => {
23
+ // Symmetric to applyLeave's `removeClasses(enter)` / `removeClasses(enterTo)`:
24
+ // clear residual leave-cycle classes — including the `leaveTo` / `enterFrom`
25
+ // class the SSR / initially-hidden render path inlines for structural
26
+ // content (see the `wasInitiallyShown` branch below). Without this, the
27
+ // SSR-baked hidden-state class would compete with `enterTo`'s CSS rules.
28
+ removeClasses(el, config.leave)
29
+ removeClasses(el, config.leaveFrom)
30
+ removeClasses(el, config.leaveTo)
31
+
23
32
  addClasses(el, config.enter)
24
33
  addClasses(el, config.enterFrom)
25
34
  if (config.enterStyle) Object.assign(el.style, config.enterStyle)
@@ -131,38 +140,91 @@ const TransitionRenderer = (props: TransitionRendererProps): VNode | null => {
131
140
  { immediate: true },
132
141
  )
133
142
 
134
- return (
135
- <Show
136
- when={shouldMount}
137
- fallback={
138
- effectiveUnmount
139
- ? null
140
- : h(
141
- props.config.tag,
142
- // mergeProps keeps every reactive HTML-attr getter; ref + the
143
- // hidden-state `display:none` style come last and win. The
144
- // one-time `props.htmlProps.style` read seeds the hidden
145
- // style display:none must compose over the user's style.
146
- mergeProps(props.htmlProps, {
147
- ref: mergedRef,
148
- style: {
149
- ...((props.htmlProps.style as CSSProperties) ?? {}),
150
- display: 'none',
151
- },
152
- }),
153
- props.children,
154
- )
155
- }
156
- >
157
- {h(
158
- props.config.tag,
159
- // Descriptor-preserving merge reactive HTML attrs keep their
160
- // getters; ref wins last. `{ ...props.htmlProps }` would freeze them.
161
- mergeProps(props.htmlProps, { ref: mergedRef }),
162
- props.children,
163
- )}
164
- </Show>
165
- )
143
+ // Initially-visible kinetic-mode Transitions keep the original Show-gated
144
+ // mount, preserving the documented runtime-unmount semantic for the
145
+ // visible→hidden transition. The SSR bug (children dropped from prerendered
146
+ // HTML) only fires for the initially-HIDDEN case below, where
147
+ // `<Show when={false}>` renders `null` on the server — leaving SSG sites
148
+ // using kinetic-mode transitions (e.g. `kinetic('div').preset(fadeUp)` with
149
+ // `show: () => false` at SSR, the scroll-reveal pattern via
150
+ // `useIntersection`) without structural content for SEO / social scrapers
151
+ // / accessibility tools / no-JS users.
152
+ //
153
+ // Mirrors the same fix shape applied to the top-level `<Transition>` in
154
+ // PR #717. Ecosystem norm (Framer Motion / react-transition-group / react-
155
+ // spring): content is structural, animation is visual.
156
+ const wasInitiallyShown = props.show()
157
+ if (wasInitiallyShown) {
158
+ return (
159
+ <Show
160
+ when={shouldMount}
161
+ fallback={
162
+ effectiveUnmount
163
+ ? null
164
+ : h(
165
+ props.config.tag,
166
+ // mergeProps keeps every reactive HTML-attr getter; ref + the
167
+ // hidden-state `display:none` style come last and win. The
168
+ // one-time `props.htmlProps.style` read seeds the hidden
169
+ // style display:none must compose over the user's style.
170
+ mergeProps(props.htmlProps, {
171
+ ref: mergedRef,
172
+ style: {
173
+ ...((props.htmlProps.style as CSSProperties) ?? {}),
174
+ display: 'none',
175
+ },
176
+ }),
177
+ props.children,
178
+ )
179
+ }
180
+ >
181
+ {h(
182
+ props.config.tag,
183
+ // Descriptor-preserving merge — reactive HTML attrs keep their
184
+ // getters; ref wins last. `{ ...props.htmlProps }` would freeze them.
185
+ mergeProps(props.htmlProps, { ref: mergedRef }),
186
+ props.children,
187
+ )}
188
+ </Show>
189
+ )
190
+ }
191
+
192
+ // Initially-hidden path — ecosystem-correct: always emit children with
193
+ // hidden-state class/style inlined so SSG / SEO / social scrapers / no-JS
194
+ // users see structural content. `leaveTo` (explicit hidden-end state)
195
+ // wins; falls back to `enterFrom` (pre-enter state) for scroll-reveal
196
+ // patterns that only configure the enter side. The existing
197
+ // `watch(stage)` effect drives the enter animation when `show` flips
198
+ // true; the symmetric `applyEnter` above clears these residual classes.
199
+ //
200
+ // Trade-off: for initially-hidden kinetic-mode Transitions, `unmount: true`
201
+ // no longer triggers a true DOM removal after a later leave animation
202
+ // completes — element stays in DOM with the leave-to class applied.
203
+ // Initially-visible Transitions (the branch above) keep the unmount
204
+ // semantic. Matches Framer Motion / react-transition-group conventions
205
+ // and is the price of SSR correctness.
206
+ // Mirrors the class picker: prefer `leaveTo`/`leaveToStyle` (explicit
207
+ // leave-end / hidden state) and fall back to `enterFrom`/`enterStyle`
208
+ // (pre-enter state). The fallback covers the preset path —
209
+ // `@pyreon/kinetic-presets` factories (fadeUp, slideLeft, blurInUp, …)
210
+ // populate `enterStyle` as the hidden state and may not set
211
+ // `leaveToStyle` at all; without this fallback, presets would SSR-render
212
+ // VISIBLE → flash-on-hydration.
213
+ const hiddenClass = props.config.leaveTo ?? props.config.enterFrom
214
+ const hiddenStyle = props.config.leaveToStyle ?? props.config.enterStyle
215
+ const childClass = props.htmlProps.class
216
+ const mergedClass = hiddenClass
217
+ ? cx([childClass as Parameters<typeof cx>[0], hiddenClass])
218
+ : undefined
219
+ const mergedStyle = hiddenStyle
220
+ ? { ...((props.htmlProps.style as CSSProperties) ?? {}), ...hiddenStyle }
221
+ : undefined
222
+
223
+ const extra: Record<string, unknown> = { ref: mergedRef }
224
+ if (mergedClass !== undefined) extra.class = mergedClass
225
+ if (mergedStyle !== undefined) extra.style = mergedStyle
226
+
227
+ return h(props.config.tag, mergeProps(props.htmlProps, extra), props.children)
166
228
  }
167
229
 
168
230
  export default TransitionRenderer
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