@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 +107 -119
- package/lib/index.js +35 -7
- package/package.json +11 -11
- package/src/Stagger.tsx +7 -2
- package/src/Transition.tsx +19 -5
- package/src/__tests__/stagger-component-children-hydration.test.tsx +191 -0
- package/src/__tests__/top-level-transition-stagger-function-children.test.tsx +141 -0
- package/src/kinetic/StaggerRenderer.tsx +4 -2
- package/src/kinetic/TransitionItem.tsx +27 -7
- package/src/utils.ts +28 -0
package/README.md
CHANGED
|
@@ -1,149 +1,131 @@
|
|
|
1
1
|
# @pyreon/kinetic
|
|
2
2
|
|
|
3
|
-
CSS-
|
|
3
|
+
CSS-transition animation library — enter/exit, stagger, collapse, list reconciliation, ~3KB.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
|
13
|
+
## Quick start
|
|
35
14
|
|
|
36
|
-
```
|
|
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
|
-
##
|
|
26
|
+
## How it compares
|
|
51
27
|
|
|
52
|
-
|
|
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
|
-
|
|
38
|
+
## `kinetic(tag)` — animated component factory
|
|
55
39
|
|
|
56
40
|
```ts
|
|
57
|
-
kinetic('div')
|
|
58
|
-
kinetic('section')
|
|
59
|
-
kinetic(MyComponent)
|
|
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
|
|
46
|
+
Returns a renderable Pyreon component with chain methods. Default mode: **transition**.
|
|
63
47
|
|
|
64
|
-
|
|
48
|
+
## Chain methods
|
|
65
49
|
|
|
66
|
-
|
|
50
|
+
Every method returns a new component (immutable). The tag generic flows through, preserving HTML attribute types.
|
|
67
51
|
|
|
68
52
|
```ts
|
|
69
|
-
//
|
|
70
|
-
.enter(styles) // CSSProperties
|
|
71
|
-
.enterTo(styles) // CSSProperties
|
|
72
|
-
.enterTransition(value) // CSS transition string
|
|
73
|
-
.leave(styles) // CSSProperties
|
|
74
|
-
.leaveTo(styles) // CSSProperties
|
|
75
|
-
.leaveTransition(value)
|
|
76
|
-
|
|
77
|
-
// Class-based
|
|
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
|
-
//
|
|
65
|
+
// Preset (spreads style + class props)
|
|
82
66
|
.preset(preset)
|
|
83
67
|
|
|
84
|
-
//
|
|
85
|
-
.config(
|
|
86
|
-
.on(
|
|
68
|
+
// Behaviour
|
|
69
|
+
.config({ appear, unmount, timeout, ... })
|
|
70
|
+
.on({ onEnter, onAfterEnter, onLeave, onAfterLeave })
|
|
87
71
|
|
|
88
72
|
// Mode switches
|
|
89
|
-
.collapse(opts?) // Height
|
|
90
|
-
.stagger(opts?) // Staggered
|
|
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
|
-
|
|
78
|
+
## Four modes
|
|
95
79
|
|
|
96
|
-
|
|
80
|
+
### Transition (default)
|
|
97
81
|
|
|
98
|
-
Single
|
|
82
|
+
Single-element enter/leave with CSS transitions.
|
|
99
83
|
|
|
100
|
-
```
|
|
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
|
-
|
|
89
|
+
### Collapse
|
|
107
90
|
|
|
108
91
|
Height animation with `overflow: hidden`. Measures `scrollHeight` automatically.
|
|
109
92
|
|
|
110
|
-
```
|
|
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
|
|
99
|
+
<Accordion show={isExpanded}>Expandable content</Accordion>
|
|
117
100
|
```
|
|
118
101
|
|
|
119
|
-
|
|
102
|
+
### Stagger
|
|
120
103
|
|
|
121
104
|
Staggered entrance/exit for child elements.
|
|
122
105
|
|
|
123
|
-
```
|
|
106
|
+
```tsx
|
|
124
107
|
const StaggerList = kinetic('ul').preset(slideUp).stagger({ interval: 75 })
|
|
125
108
|
|
|
126
|
-
StaggerList
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
116
|
+
### Group
|
|
137
117
|
|
|
138
|
-
Key-based enter/exit — adding a child triggers enter
|
|
118
|
+
Key-based enter/exit — adding a keyed child triggers enter; removing triggers leave + unmount. No `show` prop.
|
|
139
119
|
|
|
140
|
-
```
|
|
120
|
+
```tsx
|
|
141
121
|
const AnimatedList = kinetic('ul').preset(fade).group()
|
|
142
122
|
|
|
143
|
-
AnimatedList
|
|
123
|
+
<AnimatedList>
|
|
124
|
+
{items.map((item) => <li key={item.id}>{item.text}</li>)}
|
|
125
|
+
</AnimatedList>
|
|
144
126
|
```
|
|
145
127
|
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
+
## Lifecycle callbacks
|
|
171
153
|
|
|
172
|
-
```
|
|
173
|
-
FadeDiv
|
|
174
|
-
show
|
|
175
|
-
onEnter
|
|
176
|
-
onAfterEnter
|
|
177
|
-
onLeave
|
|
178
|
-
onAfterLeave
|
|
179
|
-
|
|
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
|
-
|
|
166
|
+
## Composition with rocketstyle
|
|
184
167
|
|
|
185
|
-
Kinetic
|
|
168
|
+
Kinetic and rocketstyle compose naturally:
|
|
186
169
|
|
|
187
|
-
|
|
170
|
+
```ts
|
|
171
|
+
import rocketstyle from '@pyreon/rocketstyle'
|
|
188
172
|
|
|
189
|
-
|
|
173
|
+
const Button = rocketstyle()({ component: 'button', name: 'Button' })
|
|
174
|
+
.theme({ primaryColor: 'blue' })
|
|
190
175
|
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
181
|
+
## Built-in presets
|
|
196
182
|
|
|
197
|
-
|
|
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
|
-
|
|
185
|
+
## Low-level hooks
|
|
186
|
+
|
|
187
|
+
If you need transition state outside `kinetic()`:
|
|
200
188
|
|
|
201
189
|
```ts
|
|
202
|
-
import
|
|
190
|
+
import { useTransitionState, useAnimationEnd } from '@pyreon/kinetic'
|
|
203
191
|
|
|
204
|
-
const
|
|
205
|
-
|
|
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
|
-
|
|
196
|
+
## Accessibility
|
|
209
197
|
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
//
|
|
227
|
-
//
|
|
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
|
|
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
|
-
##
|
|
228
|
+
## Documentation
|
|
238
229
|
|
|
239
|
-
|
|
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
|
|
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(
|
|
432
|
+
fallback: unmount ? null : cloneVNode(child, {
|
|
406
433
|
ref: mergedRef,
|
|
407
|
-
style: mergeStyles(props
|
|
434
|
+
style: mergeStyles((child?.props)?.style, { display: "none" })
|
|
408
435
|
}),
|
|
409
|
-
children: cloneVNode(
|
|
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
|
|
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(
|
|
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
|
|
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.
|
|
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.
|
|
46
|
-
"@pyreon/reactivity": "^0.
|
|
47
|
-
"@pyreon/runtime-dom": "^0.
|
|
48
|
-
"@pyreon/runtime-server": "^0.
|
|
49
|
-
"@pyreon/test-utils": "^0.13.
|
|
50
|
-
"@pyreon/typescript": "^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.
|
|
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.
|
|
59
|
-
"@pyreon/reactivity": "^0.
|
|
60
|
-
"@pyreon/runtime-dom": "^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
|
-
|
|
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 (
|
package/src/Transition.tsx
CHANGED
|
@@ -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 {
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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(
|
|
205
|
+
: cloneVNode(child, {
|
|
186
206
|
ref: mergedRef,
|
|
187
207
|
style: mergeStyles(
|
|
188
|
-
(props
|
|
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(
|
|
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
|
|
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(
|
|
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
|