@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.
- package/README.md +107 -119
- package/lib/index.js +73 -13
- package/package.json +11 -11
- package/src/Stagger.tsx +7 -2
- package/src/Transition.tsx +30 -6
- package/src/__tests__/Collapse.test.tsx +25 -4
- package/src/__tests__/Transition.ssr.test.tsx +28 -0
- package/src/__tests__/kinetic-modes.ssr.test.tsx +214 -0
- package/src/__tests__/kinetic.browser.test.tsx +113 -0
- 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/CollapseRenderer.tsx +34 -3
- package/src/kinetic/StaggerRenderer.tsx +4 -2
- package/src/kinetic/TransitionItem.tsx +92 -22
- package/src/kinetic/TransitionRenderer.tsx +95 -33
- package/src/utils.ts +28 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|