@pyreon/kinetic 0.19.0 → 0.21.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 +23 -0
- package/lib/index.js +11 -18
- package/package.json +10 -9
- package/src/Transition.tsx +81 -18
- package/src/__tests__/Transition.ssr.test.tsx +155 -0
- package/src/__tests__/kinetic.browser.test.tsx +153 -0
- package/src/kinetic/CollapseRenderer.tsx +9 -2
- package/src/kinetic/GroupRenderer.tsx +3 -1
- package/src/kinetic/StaggerRenderer.tsx +5 -1
- package/src/kinetic/TransitionRenderer.tsx +14 -5
- package/src/kinetic/createKineticComponent.tsx +26 -13
package/README.md
CHANGED
|
@@ -211,6 +211,29 @@ const AnimatedButton = kinetic(Button).preset(fade)
|
|
|
211
211
|
AnimatedButton({ show: isVisible, primary: true, size: 'large', children: 'Click me' })
|
|
212
212
|
```
|
|
213
213
|
|
|
214
|
+
## SSR / SSG
|
|
215
|
+
|
|
216
|
+
`<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
|
+
|
|
218
|
+
This is load-bearing for the scroll-reveal pattern on SSG sites — `useIntersection` can't fire on the server, so `show` is false at SSR. Without structural rendering, the wrapped content would be absent from prerendered HTML (bad for SEO, social scrapers, no-JS users).
|
|
219
|
+
|
|
220
|
+
```tsx
|
|
221
|
+
const RevealSection = kinetic('section')
|
|
222
|
+
.enter('transition-all duration-700')
|
|
223
|
+
.enterFrom('opacity-0 translate-y-8') // ← this IS the SSR hidden state
|
|
224
|
+
.enterTo('opacity-100 translate-y-0')
|
|
225
|
+
|
|
226
|
+
// In your route — show is driven by useIntersection on the client.
|
|
227
|
+
// At SSR: <section class="opacity-0 translate-y-8">…full content here…</section>
|
|
228
|
+
// On client: when scrolled into view, show flips true, enter animation runs.
|
|
229
|
+
<RevealSection show={isInView}>
|
|
230
|
+
<h2>Work Experience</h2>
|
|
231
|
+
<p>…content reaches SEO crawlers and social scrapers…</p>
|
|
232
|
+
</RevealSection>
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
**Trade-off**: for an initially-hidden Transition, `unmount: true` (the default) no longer triggers a true DOM removal after a later leave animation completes — the element stays in DOM with the leave-to class applied. **Initially-visible** Transitions (`show: () => true` at setup) keep the runtime-unmount semantic unchanged. If you need true unmount on a started-hidden element, drive mount/unmount yourself outside `<Transition>`.
|
|
236
|
+
|
|
214
237
|
## Peer Dependencies
|
|
215
238
|
|
|
216
239
|
| Package | Version |
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Show, createRef, h, onMount, onUnmount } from "@pyreon/core";
|
|
1
|
+
import { Show, createRef, h, mergeProps, onMount, onUnmount, splitProps } from "@pyreon/core";
|
|
2
2
|
import { runUntracked, signal, watch } from "@pyreon/reactivity";
|
|
3
3
|
import { jsx } from "@pyreon/core/jsx-runtime";
|
|
4
4
|
|
|
@@ -172,11 +172,10 @@ const CollapseRenderer = ({ config, htmlProps, show, appear, timeout, transition
|
|
|
172
172
|
...stage() !== "entered" ? { overflow: "hidden" } : {},
|
|
173
173
|
...stage() === "hidden" ? { height: "0px" } : stage() === "entered" ? { height: "auto" } : {}
|
|
174
174
|
};
|
|
175
|
-
return h(config.tag, {
|
|
175
|
+
return h(config.tag, mergeProps(htmlProps, {
|
|
176
176
|
ref: wrapperRef,
|
|
177
|
-
...htmlProps,
|
|
178
177
|
style: wrapperStyle
|
|
179
|
-
}, /* @__PURE__ */ jsx(Show, {
|
|
178
|
+
}), /* @__PURE__ */ jsx(Show, {
|
|
180
179
|
when: shouldRender,
|
|
181
180
|
children: /* @__PURE__ */ jsx("div", {
|
|
182
181
|
ref: contentRef,
|
|
@@ -479,7 +478,7 @@ const GroupRenderer = ({ config, htmlProps, appear, timeout, callbacks, children
|
|
|
479
478
|
children: element
|
|
480
479
|
});
|
|
481
480
|
});
|
|
482
|
-
return h(config.tag,
|
|
481
|
+
return h(config.tag, htmlProps, ...groupedChildren);
|
|
483
482
|
});
|
|
484
483
|
};
|
|
485
484
|
|
|
@@ -526,7 +525,7 @@ const StaggerRenderer = ({ config, htmlProps, show, appear, timeout, interval, r
|
|
|
526
525
|
} })
|
|
527
526
|
}, child.key ?? index);
|
|
528
527
|
});
|
|
529
|
-
return h(config.tag,
|
|
528
|
+
return h(config.tag, htmlProps, ...staggeredChildren);
|
|
530
529
|
};
|
|
531
530
|
|
|
532
531
|
//#endregion
|
|
@@ -613,18 +612,14 @@ const TransitionRenderer = (props) => {
|
|
|
613
612
|
}, { immediate: true });
|
|
614
613
|
return /* @__PURE__ */ jsx(Show, {
|
|
615
614
|
when: shouldMount,
|
|
616
|
-
fallback: effectiveUnmount ? null : h(props.config.tag, {
|
|
615
|
+
fallback: effectiveUnmount ? null : h(props.config.tag, mergeProps(props.htmlProps, {
|
|
617
616
|
ref: mergedRef,
|
|
618
|
-
...props.htmlProps,
|
|
619
617
|
style: {
|
|
620
618
|
...props.htmlProps.style ?? {},
|
|
621
619
|
display: "none"
|
|
622
620
|
}
|
|
623
|
-
}, props.children),
|
|
624
|
-
children: h(props.config.tag, {
|
|
625
|
-
ref: mergedRef,
|
|
626
|
-
...props.htmlProps
|
|
627
|
-
}, props.children)
|
|
621
|
+
}), props.children),
|
|
622
|
+
children: h(props.config.tag, mergeProps(props.htmlProps, { ref: mergedRef }), props.children)
|
|
628
623
|
});
|
|
629
624
|
};
|
|
630
625
|
|
|
@@ -651,10 +646,7 @@ const KINETIC_KEYS = new Set([
|
|
|
651
646
|
*/
|
|
652
647
|
const createKineticComponent = (config) => {
|
|
653
648
|
const Component = (props) => {
|
|
654
|
-
const
|
|
655
|
-
const kineticProps = {};
|
|
656
|
-
for (const key in props) if (KINETIC_KEYS.has(key)) kineticProps[key] = props[key];
|
|
657
|
-
else htmlProps[key] = props[key];
|
|
649
|
+
const [kineticProps, htmlPropsWithChildren] = splitProps(props, [...KINETIC_KEYS]);
|
|
658
650
|
const { show, appear, unmount, timeout, transition, interval, reverseLeave, onEnter, onAfterEnter, onLeave, onAfterLeave } = kineticProps;
|
|
659
651
|
const callbacks = {
|
|
660
652
|
onEnter: onEnter ?? config.onEnter,
|
|
@@ -662,7 +654,8 @@ const createKineticComponent = (config) => {
|
|
|
662
654
|
onLeave: onLeave ?? config.onLeave,
|
|
663
655
|
onAfterLeave: onAfterLeave ?? config.onAfterLeave
|
|
664
656
|
};
|
|
665
|
-
const
|
|
657
|
+
const [childHolder, restHtml] = splitProps(htmlPropsWithChildren, ["children"]);
|
|
658
|
+
const children = childHolder.children;
|
|
666
659
|
if (config.mode === "collapse") return /* @__PURE__ */ jsx(CollapseRenderer, {
|
|
667
660
|
config,
|
|
668
661
|
htmlProps: restHtml,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/kinetic",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "CSS-transition-based animation components for Pyreon",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -42,11 +42,12 @@
|
|
|
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/
|
|
49
|
-
"@pyreon/
|
|
45
|
+
"@pyreon/core": "^0.21.0",
|
|
46
|
+
"@pyreon/reactivity": "^0.21.0",
|
|
47
|
+
"@pyreon/runtime-dom": "^0.21.0",
|
|
48
|
+
"@pyreon/runtime-server": "^0.21.0",
|
|
49
|
+
"@pyreon/test-utils": "^0.13.8",
|
|
50
|
+
"@pyreon/typescript": "^0.21.0",
|
|
50
51
|
"@vitest/browser-playwright": "^4.1.4",
|
|
51
52
|
"@vitus-labs/tools-rolldown": "^2.3.0"
|
|
52
53
|
},
|
|
@@ -54,8 +55,8 @@
|
|
|
54
55
|
"node": ">= 22"
|
|
55
56
|
},
|
|
56
57
|
"dependencies": {
|
|
57
|
-
"@pyreon/core": "^0.
|
|
58
|
-
"@pyreon/reactivity": "^0.
|
|
59
|
-
"@pyreon/runtime-dom": "^0.
|
|
58
|
+
"@pyreon/core": "^0.21.0",
|
|
59
|
+
"@pyreon/reactivity": "^0.21.0",
|
|
60
|
+
"@pyreon/runtime-dom": "^0.21.0"
|
|
60
61
|
}
|
|
61
62
|
}
|
package/src/Transition.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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, TransitionProps } from './types'
|
|
5
5
|
import useAnimationEnd from './useAnimationEnd'
|
|
@@ -16,8 +16,22 @@ const applyEnter = (
|
|
|
16
16
|
enterStyle,
|
|
17
17
|
enterToStyle,
|
|
18
18
|
enterTransition,
|
|
19
|
+
leave,
|
|
20
|
+
leaveFrom,
|
|
21
|
+
leaveTo,
|
|
19
22
|
}: ClassTransitionProps & StyleTransitionProps,
|
|
20
23
|
) => {
|
|
24
|
+
// Symmetric to applyLeave's `removeClasses(enter)` / `removeClasses(enterTo)`:
|
|
25
|
+
// clear any residual leave-cycle classes — including the `leaveTo` /
|
|
26
|
+
// `enterFrom` class the SSR / initial-hidden render path inlines for
|
|
27
|
+
// ecosystem-correct structural content (see the `wasInitiallyShown`
|
|
28
|
+
// branch below). Without this, the SSR-baked hidden-state class would
|
|
29
|
+
// compete with `enterTo`'s CSS rules and the enter animation would
|
|
30
|
+
// visually fight itself.
|
|
31
|
+
removeClasses(el, leave)
|
|
32
|
+
removeClasses(el, leaveFrom)
|
|
33
|
+
removeClasses(el, leaveTo)
|
|
34
|
+
|
|
21
35
|
addClasses(el, enter)
|
|
22
36
|
addClasses(el, enterFrom)
|
|
23
37
|
if (enterStyle) Object.assign(el.style, enterStyle)
|
|
@@ -170,24 +184,73 @@ const Transition = (props: TransitionProps): VNode | null => {
|
|
|
170
184
|
{ immediate: true },
|
|
171
185
|
)
|
|
172
186
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
187
|
+
// Initially-visible Transitions keep the original Show-gated mount,
|
|
188
|
+
// which preserves the documented runtime-unmount semantic for the
|
|
189
|
+
// visible → hidden transition (modal close, dropdown collapse, etc.).
|
|
190
|
+
// The SSR bug (children dropped from prerendered HTML) only fires for
|
|
191
|
+
// the initially-HIDDEN case below, because `<Show when={false}>`
|
|
192
|
+
// renders `null` on the server.
|
|
193
|
+
const wasInitiallyShown = props.show()
|
|
194
|
+
if (wasInitiallyShown) {
|
|
195
|
+
return (
|
|
196
|
+
<Show
|
|
197
|
+
when={shouldMount}
|
|
198
|
+
fallback={
|
|
199
|
+
unmount
|
|
200
|
+
? null
|
|
201
|
+
: cloneVNode(props.children, {
|
|
202
|
+
ref: mergedRef,
|
|
203
|
+
style: mergeStyles(
|
|
204
|
+
childProps.style as Record<string, string | number | undefined> | undefined,
|
|
205
|
+
{ display: 'none' },
|
|
206
|
+
),
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
>
|
|
210
|
+
{cloneVNode(props.children, { ref: mergedRef })}
|
|
211
|
+
</Show>
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Initially-hidden path — ecosystem-correct (Framer Motion / react-
|
|
216
|
+
// transition-group / react-spring all render children in SSR regardless
|
|
217
|
+
// of animation state; visual hiding is class/style only). Always emits
|
|
218
|
+
// children so SSG / SEO / social scrapers / no-JS users see the
|
|
219
|
+
// structural content. The hidden visual is supplied by `leaveTo`
|
|
220
|
+
// (explicit hidden-end state) or `enterFrom` (pre-enter state — covers
|
|
221
|
+
// the scroll-reveal pattern that only configures the enter side).
|
|
222
|
+
//
|
|
223
|
+
// Trade-off: for an initially-hidden Transition, `unmount: true` no
|
|
224
|
+
// longer triggers a true DOM removal after a later leave animation
|
|
225
|
+
// completes — the element stays in DOM with the leave-to class
|
|
226
|
+
// applied. Initially-visible Transitions keep the unmount semantic
|
|
227
|
+
// (the branch above). This matches Framer Motion / react-transition-
|
|
228
|
+
// group conventions and is the price of SSR correctness; the rare
|
|
229
|
+
// user who needs true unmount on a started-hidden element can drive
|
|
230
|
+
// mount/unmount themselves outside `<Transition>`.
|
|
231
|
+
//
|
|
232
|
+
// The `watch(stage)` effect above drives the enter animation when
|
|
233
|
+
// `show` flips true; `applyEnter` (above) clears these residual
|
|
234
|
+
// hidden-state classes so they don't fight `enterTo`.
|
|
235
|
+
const hiddenClass = props.leaveTo ?? props.enterFrom
|
|
236
|
+
const hiddenStyle = props.leaveToStyle
|
|
237
|
+
const childClass = childProps.class
|
|
238
|
+
const mergedClass = hiddenClass
|
|
239
|
+
? cx([childClass as Parameters<typeof cx>[0], hiddenClass])
|
|
240
|
+
: undefined
|
|
241
|
+
const mergedStyle = mergeStyles(
|
|
242
|
+
childProps.style as Record<string, string | number | undefined> | undefined,
|
|
243
|
+
hiddenStyle,
|
|
190
244
|
)
|
|
245
|
+
|
|
246
|
+
// Build extra-props carefully — undefined values must NOT be passed to
|
|
247
|
+
// cloneVNode because `{...vnode.props, ...extraProps}` spreads them and
|
|
248
|
+
// overrides any user-set `class`/`style` on the child vnode with undefined.
|
|
249
|
+
const extra: Record<string, unknown> = { ref: mergedRef }
|
|
250
|
+
if (mergedClass !== undefined) extra.class = mergedClass
|
|
251
|
+
if (mergedStyle !== undefined) extra.style = mergedStyle
|
|
252
|
+
|
|
253
|
+
return cloneVNode(props.children, extra)
|
|
191
254
|
}
|
|
192
255
|
|
|
193
256
|
export default Transition
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSR regression coverage for `<Transition>` (the missing test layer that
|
|
3
|
+
* let the children-dropped-on-SSR bug ship).
|
|
4
|
+
*
|
|
5
|
+
* Background. `<Transition show={() => false} ...>` used to render `<Show
|
|
6
|
+
* when={false} fallback={null}>` on the server, which emitted EMPTY HTML —
|
|
7
|
+
* any SSG site using kinetic for scroll-triggered reveal (the documented
|
|
8
|
+
* `useIntersection` + sticky-signal pattern, where `show` is false at SSR
|
|
9
|
+
* because IntersectionObserver can't fire until client hydration) shipped
|
|
10
|
+
* with the wrapped content STRUCTURALLY ABSENT from the prerendered HTML.
|
|
11
|
+
* Bad for SEO, social scrapers, accessibility tools, and no-JS users.
|
|
12
|
+
*
|
|
13
|
+
* Ecosystem norm (the framing this fix aligns Pyreon with): Framer Motion,
|
|
14
|
+
* react-transition-group, react-spring, AutoAnimate all render children in
|
|
15
|
+
* SSR regardless of animation state and only apply animation styles on the
|
|
16
|
+
* client. "Content is structural, animation is visual."
|
|
17
|
+
*
|
|
18
|
+
* What the fix changes. `Transition` now branches at setup on
|
|
19
|
+
* `props.show()`:
|
|
20
|
+
* - initially-visible → existing `<Show>`-gated mount (unchanged;
|
|
21
|
+
* preserves the runtime-unmount semantic for the visible→hidden case)
|
|
22
|
+
* - initially-hidden → always render children with hidden-state classes
|
|
23
|
+
* inlined (`leaveTo` if defined, else `enterFrom` — covers the
|
|
24
|
+
* scroll-reveal pattern that only configures the enter side). The
|
|
25
|
+
* existing `watch(stage)` effect drives the enter animation when
|
|
26
|
+
* `show` flips true.
|
|
27
|
+
*
|
|
28
|
+
* Why these tests are load-bearing. Zero existing tests exercised
|
|
29
|
+
* `show: () => false` initial state (the bug class). The fact that this
|
|
30
|
+
* shipped is exactly the "no test catches it because no test runs the real
|
|
31
|
+
* path" failure mode the `test-environment-parity.md` rule was written to
|
|
32
|
+
* prevent — both real `h()` AND a SSR-driving environment (`renderToString`)
|
|
33
|
+
* are needed to catch it.
|
|
34
|
+
*
|
|
35
|
+
* Bisect-verified: reverting `Transition.tsx`'s `wasInitiallyShown` branch
|
|
36
|
+
* fails every spec below with `expected '' to contain '...'` (the empty-
|
|
37
|
+
* children bug). Restored → all green.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { h } from '@pyreon/core'
|
|
41
|
+
import { renderToString } from '@pyreon/runtime-server'
|
|
42
|
+
import { describe, expect, it } from 'vitest'
|
|
43
|
+
import Transition from '../Transition'
|
|
44
|
+
|
|
45
|
+
describe('Transition — SSR / initially-hidden children render', () => {
|
|
46
|
+
it('emits children when show=false initially (was: <Show fallback={null}> → empty)', async () => {
|
|
47
|
+
// The canonical bug shape — scroll-reveal pattern at SSR time.
|
|
48
|
+
const html = await renderToString(
|
|
49
|
+
h(Transition, {
|
|
50
|
+
show: () => false,
|
|
51
|
+
enterFrom: 'opacity-0',
|
|
52
|
+
enterTo: 'opacity-100',
|
|
53
|
+
enter: 'transition-opacity duration-300',
|
|
54
|
+
children: h('section', null, 'real content for SEO + social scrapers'),
|
|
55
|
+
}),
|
|
56
|
+
)
|
|
57
|
+
// Structural content must land in the prerendered HTML. Pre-fix this
|
|
58
|
+
// assertion failed with `expected '' to contain '...'`.
|
|
59
|
+
expect(html).toContain('<section')
|
|
60
|
+
expect(html).toContain('real content for SEO + social scrapers')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('inlines `leaveTo` as the hidden-state class (explicit hidden-end state takes precedence)', async () => {
|
|
64
|
+
const html = await renderToString(
|
|
65
|
+
h(Transition, {
|
|
66
|
+
show: () => false,
|
|
67
|
+
enterFrom: 'opacity-0 translate-y-4', // present but NOT selected
|
|
68
|
+
enterTo: 'opacity-100 translate-y-0',
|
|
69
|
+
leave: 'transition-opacity',
|
|
70
|
+
leaveFrom: 'opacity-100',
|
|
71
|
+
leaveTo: 'is-hidden opacity-0', // ← explicit hidden-end state, selected
|
|
72
|
+
children: h('div', null, 'panel content'),
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
75
|
+
expect(html).toContain('is-hidden opacity-0')
|
|
76
|
+
expect(html).toContain('panel content')
|
|
77
|
+
// The competing `enterFrom` should NOT be applied (leaveTo wins).
|
|
78
|
+
expect(html).not.toContain('translate-y-4')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('falls back to `enterFrom` as the hidden class for scroll-reveal patterns (only enter side configured)', async () => {
|
|
82
|
+
// The exact pattern the reported bug surfaced on — only the enter
|
|
83
|
+
// animation is configured (because IO triggers `show` true; there's
|
|
84
|
+
// no leave side for the reveal pattern).
|
|
85
|
+
const html = await renderToString(
|
|
86
|
+
h(Transition, {
|
|
87
|
+
show: () => false,
|
|
88
|
+
enter: 'transition-all duration-700',
|
|
89
|
+
enterFrom: 'opacity-0 translate-y-8',
|
|
90
|
+
enterTo: 'opacity-100 translate-y-0',
|
|
91
|
+
children: h('section', { id: 'resume-section' }, 'work history goes here'),
|
|
92
|
+
}),
|
|
93
|
+
)
|
|
94
|
+
expect(html).toContain('id="resume-section"')
|
|
95
|
+
expect(html).toContain('work history goes here')
|
|
96
|
+
// enterFrom IS the resting hidden state for this pattern.
|
|
97
|
+
expect(html).toContain('opacity-0 translate-y-8')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('inlines `leaveToStyle` as the hidden inline style when defined', async () => {
|
|
101
|
+
const html = await renderToString(
|
|
102
|
+
h(Transition, {
|
|
103
|
+
show: () => false,
|
|
104
|
+
leaveTo: 'animated-section',
|
|
105
|
+
leaveToStyle: { opacity: 0, transform: 'translateY(20px)' },
|
|
106
|
+
children: h('article', null, 'article body'),
|
|
107
|
+
}),
|
|
108
|
+
)
|
|
109
|
+
expect(html).toContain('article body')
|
|
110
|
+
expect(html).toContain('opacity: 0')
|
|
111
|
+
expect(html).toContain('translateY(20px)')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('merges the hidden class with any user-set class on the child', async () => {
|
|
115
|
+
const html = await renderToString(
|
|
116
|
+
h(Transition, {
|
|
117
|
+
show: () => false,
|
|
118
|
+
leaveTo: 'is-hidden',
|
|
119
|
+
children: h('div', { class: 'card card--featured' }, 'merged-class content'),
|
|
120
|
+
}),
|
|
121
|
+
)
|
|
122
|
+
expect(html).toContain('merged-class content')
|
|
123
|
+
expect(html).toContain('card')
|
|
124
|
+
expect(html).toContain('card--featured')
|
|
125
|
+
expect(html).toContain('is-hidden')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('emits children unchanged when neither leaveTo nor enterFrom is defined (graceful no-op)', async () => {
|
|
129
|
+
// An unusual config — no enter/leave classes at all. Children should
|
|
130
|
+
// still render structurally (the SEO/SSG contract); no hidden class
|
|
131
|
+
// is appended because there's nothing to append.
|
|
132
|
+
const html = await renderToString(
|
|
133
|
+
h(Transition, {
|
|
134
|
+
show: () => false,
|
|
135
|
+
children: h('div', null, 'bare content'),
|
|
136
|
+
}),
|
|
137
|
+
)
|
|
138
|
+
expect(html).toContain('bare content')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('initially-visible Transition (show=true) renders children normally — unchanged behavior', async () => {
|
|
142
|
+
// The other branch of the fix — initially-visible Transitions keep
|
|
143
|
+
// the original `<Show>`-gated path. This spec locks in the no-
|
|
144
|
+
// regression contract for the existing common case.
|
|
145
|
+
const html = await renderToString(
|
|
146
|
+
h(Transition, {
|
|
147
|
+
show: () => true,
|
|
148
|
+
leaveTo: 'is-hidden', // must NOT leak onto initially-visible
|
|
149
|
+
children: h('main', null, 'visible from the start'),
|
|
150
|
+
}),
|
|
151
|
+
)
|
|
152
|
+
expect(html).toContain('visible from the start')
|
|
153
|
+
expect(html).not.toContain('is-hidden')
|
|
154
|
+
})
|
|
155
|
+
})
|
|
@@ -1,11 +1,42 @@
|
|
|
1
1
|
/** @jsxImportSource @pyreon/core */
|
|
2
2
|
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { _rp, h } from '@pyreon/core'
|
|
3
4
|
import { signal } from '@pyreon/reactivity'
|
|
4
5
|
import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
|
|
6
|
+
import kinetic from '../kinetic'
|
|
5
7
|
import { nextFrame, mergeClassNames } from '../utils'
|
|
6
8
|
import Transition from '../Transition'
|
|
7
9
|
|
|
8
10
|
describe('@pyreon/kinetic browser smoke', () => {
|
|
11
|
+
// Regression: createKineticComponent + the 4 renderers used to value-copy
|
|
12
|
+
// user props (`for…in` / `const { children, ...rest }` / `{ ...htmlProps }`),
|
|
13
|
+
// firing every getter at component-setup time. The compiler emits a
|
|
14
|
+
// reactive HTML attr as `_rp(() => sig())`; mount.ts's makeReactiveProps
|
|
15
|
+
// turns it into a getter on `props`. The value-copy collapsed that getter
|
|
16
|
+
// to a static snapshot, freezing the attribute forever. The fix routes
|
|
17
|
+
// every hop through descriptor-preserving splitProps / mergeProps / by-ref
|
|
18
|
+
// so runtime-dom's applyProps detects the getter descriptor and wraps the
|
|
19
|
+
// read in a renderEffect. Bisect-verified: reverting createKineticComponent's
|
|
20
|
+
// splitProps split back to `for…in` fails this with `expected 'a' to be 'b'`.
|
|
21
|
+
it('forwards a reactive HTML attr through the kinetic pipeline (descriptor-preserving)', async () => {
|
|
22
|
+
const FadeDiv = kinetic('div')
|
|
23
|
+
const show = signal(true)
|
|
24
|
+
const v = signal('a')
|
|
25
|
+
const { container, unmount } = mountInBrowser(
|
|
26
|
+
h(
|
|
27
|
+
FadeDiv,
|
|
28
|
+
{ show, 'data-testid': 'fd', 'data-variant': _rp(() => v()) },
|
|
29
|
+
h('span', { 'data-id': 'kc' }, 'hi'),
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
const el = () => container.querySelector('[data-testid="fd"]')
|
|
33
|
+
expect(el()?.getAttribute('data-variant')).toBe('a')
|
|
34
|
+
v.set('b')
|
|
35
|
+
await flush()
|
|
36
|
+
expect(el()?.getAttribute('data-variant')).toBe('b')
|
|
37
|
+
unmount()
|
|
38
|
+
})
|
|
39
|
+
|
|
9
40
|
it('Transition mounts a visible child into real DOM', async () => {
|
|
10
41
|
const show = signal(true)
|
|
11
42
|
const { container, unmount } = mountInBrowser(
|
|
@@ -51,6 +82,61 @@ describe('@pyreon/kinetic browser smoke', () => {
|
|
|
51
82
|
expect(mergeClassNames(undefined, undefined)).toBe(undefined)
|
|
52
83
|
})
|
|
53
84
|
|
|
85
|
+
// Regression: a kinetic-wrapped component must FORWARD a compiler-shaped
|
|
86
|
+
// reactive HTML attr (`<KineticDiv class={sig()}>` → `_rp(() => sig())`,
|
|
87
|
+
// which `makeReactiveProps` turns into a getter on `props`) so the DOM
|
|
88
|
+
// patches when the signal changes. The factory's prop split + the
|
|
89
|
+
// renderers' element spread used to value-copy props, firing the getter
|
|
90
|
+
// once at setup and freezing the attribute. We build the vnode with
|
|
91
|
+
// `h()` + `_rp()` directly because this browser config has no Pyreon
|
|
92
|
+
// compiler plugin — that faithfully reproduces the exact post-
|
|
93
|
+
// makeReactiveProps shape the mount pipeline sees in a real app.
|
|
94
|
+
//
|
|
95
|
+
// Bisect-verified: revert createKineticComponent's splitProps back to
|
|
96
|
+
// `htmlProps[key] = props[key]` → this fails with the className stuck
|
|
97
|
+
// at 'one' (`expected 'one' to be 'two'`). Restored → passes.
|
|
98
|
+
it('forwards a compiler-shaped reactive HTML attr — DOM patches on signal change (transition mode)', async () => {
|
|
99
|
+
const KineticDiv = kinetic('div')
|
|
100
|
+
const cls = signal('one')
|
|
101
|
+
const { container, unmount } = mountInBrowser(
|
|
102
|
+
h(
|
|
103
|
+
KineticDiv,
|
|
104
|
+
{ show: () => true, class: _rp(() => cls()) },
|
|
105
|
+
h('span', { 'data-id': 'k' }, 'x'),
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
const el = () => container.querySelector('div')
|
|
109
|
+
expect(el()?.querySelector('[data-id="k"]')?.textContent).toBe('x')
|
|
110
|
+
expect(el()?.className).toBe('one')
|
|
111
|
+
|
|
112
|
+
cls.set('two')
|
|
113
|
+
await flush()
|
|
114
|
+
expect(el()?.className).toBe('two')
|
|
115
|
+
|
|
116
|
+
cls.set('three')
|
|
117
|
+
await flush()
|
|
118
|
+
expect(el()?.className).toBe('three')
|
|
119
|
+
unmount()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('forwards a compiler-shaped reactive HTML attr — collapse mode (mergeProps path)', async () => {
|
|
123
|
+
const KineticDiv = kinetic('div').collapse()
|
|
124
|
+
const cls = signal('a')
|
|
125
|
+
const { container, unmount } = mountInBrowser(
|
|
126
|
+
h(
|
|
127
|
+
KineticDiv,
|
|
128
|
+
{ show: () => true, class: _rp(() => cls()) },
|
|
129
|
+
h('span', { 'data-id': 'c' }, 'y'),
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
const el = () => container.querySelector('div')
|
|
133
|
+
expect(el()?.className).toBe('a')
|
|
134
|
+
cls.set('b')
|
|
135
|
+
await flush()
|
|
136
|
+
expect(el()?.className).toBe('b')
|
|
137
|
+
unmount()
|
|
138
|
+
})
|
|
139
|
+
|
|
54
140
|
it('runs in a real browser — Vitest defines `process.env.NODE_ENV !== "production"`', () => {
|
|
55
141
|
// Sanity check the test env: dev gates use bundler-agnostic
|
|
56
142
|
// `process.env.NODE_ENV !== 'production'`. Vitest's Vite pipeline
|
|
@@ -58,4 +144,71 @@ describe('@pyreon/kinetic browser smoke', () => {
|
|
|
58
144
|
// `"development" !== "production"` → `true` in dev runs.
|
|
59
145
|
expect(process.env.NODE_ENV).not.toBe('production')
|
|
60
146
|
})
|
|
147
|
+
|
|
148
|
+
// ── Initially-hidden Transition: client-side parity with the SSR fix ─────
|
|
149
|
+
//
|
|
150
|
+
// The SSR test file (`Transition.ssr.test.tsx`) proves children land in
|
|
151
|
+
// prerendered HTML; these specs prove the SAME render path works under
|
|
152
|
+
// a real DOM — the element mounts with the hidden-state class applied,
|
|
153
|
+
// and an `applyEnter` triggered by a `show` flip cleanly transitions it
|
|
154
|
+
// out of the hidden state (the companion `applyEnter` fix that removes
|
|
155
|
+
// residual `leave`/`leaveFrom`/`leaveTo` classes ensures the SSR-baked
|
|
156
|
+
// hidden class doesn't fight `enterTo`).
|
|
157
|
+
|
|
158
|
+
it('Transition with initial show=false mounts the element with the hidden class (no null)', async () => {
|
|
159
|
+
const show = signal(false)
|
|
160
|
+
const { container, unmount } = mountInBrowser(
|
|
161
|
+
<Transition
|
|
162
|
+
show={show}
|
|
163
|
+
enterFrom="hide-state"
|
|
164
|
+
enterTo="show-state"
|
|
165
|
+
enter="transition-opacity"
|
|
166
|
+
>
|
|
167
|
+
<div data-id="reveal-target">scroll-reveal content</div>
|
|
168
|
+
</Transition>,
|
|
169
|
+
)
|
|
170
|
+
// Pre-fix: container.querySelector returns null (children were dropped).
|
|
171
|
+
const el = container.querySelector('[data-id="reveal-target"]') as HTMLElement | null
|
|
172
|
+
expect(el).not.toBeNull()
|
|
173
|
+
expect(el!.textContent).toBe('scroll-reveal content')
|
|
174
|
+
// enterFrom is the fallback hidden-state class (scroll-reveal pattern
|
|
175
|
+
// configures only the enter side).
|
|
176
|
+
expect(el!.classList.contains('hide-state')).toBe(true)
|
|
177
|
+
unmount()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('flipping show=true on an initially-hidden Transition cleans the hidden class and runs enter', async () => {
|
|
181
|
+
const show = signal(false)
|
|
182
|
+
const { container, unmount } = mountInBrowser(
|
|
183
|
+
<Transition
|
|
184
|
+
show={show}
|
|
185
|
+
enterFrom="hide-state"
|
|
186
|
+
enterTo="show-state"
|
|
187
|
+
enter="enter-active"
|
|
188
|
+
>
|
|
189
|
+
<div data-id="reveal-target">content</div>
|
|
190
|
+
</Transition>,
|
|
191
|
+
)
|
|
192
|
+
const el = () => container.querySelector('[data-id="reveal-target"]') as HTMLElement | null
|
|
193
|
+
// Starts hidden.
|
|
194
|
+
expect(el()!.classList.contains('hide-state')).toBe(true)
|
|
195
|
+
// Flip show → true; applyEnter runs in the watch effect on the SAME
|
|
196
|
+
// element (the SSR fix guarantees the element is already in DOM).
|
|
197
|
+
show.set(true)
|
|
198
|
+
await flush()
|
|
199
|
+
// The companion applyEnter fix removes residual `leave`/`leaveFrom`/
|
|
200
|
+
// `leaveTo` AND adds `enter` + `enterFrom`. enterFrom was already
|
|
201
|
+
// applied (it WAS the hidden-state class); the next frame removes it
|
|
202
|
+
// and adds enterTo. Two rAFs for full transition.
|
|
203
|
+
await new Promise<void>((resolve) =>
|
|
204
|
+
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
|
205
|
+
)
|
|
206
|
+
await flush()
|
|
207
|
+
// After the double-rAF, enterTo is applied + enterFrom removed.
|
|
208
|
+
expect(el()!.classList.contains('show-state')).toBe(true)
|
|
209
|
+
expect(el()!.classList.contains('hide-state')).toBe(false)
|
|
210
|
+
// `enter` (the active marker) is applied throughout the transition.
|
|
211
|
+
expect(el()!.classList.contains('enter-active')).toBe(true)
|
|
212
|
+
unmount()
|
|
213
|
+
})
|
|
61
214
|
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { VNode } from '@pyreon/core'
|
|
2
|
-
import { createRef, h, Show } from '@pyreon/core'
|
|
2
|
+
import { createRef, h, mergeProps, Show } from '@pyreon/core'
|
|
3
3
|
import { runUntracked, signal, watch } from '@pyreon/reactivity'
|
|
4
4
|
import type { CSSProperties, TransitionCallbacks, TransitionStage } from '../types'
|
|
5
5
|
import useAnimationEnd from '../useAnimationEnd'
|
|
@@ -166,9 +166,16 @@ const CollapseRenderer = ({
|
|
|
166
166
|
...(stage() === 'hidden' ? { height: '0px' } : stage() === 'entered' ? { height: 'auto' } : {}),
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
// mergeProps (descriptor-preserving) instead of `{ ...htmlProps }` —
|
|
170
|
+
// every non-style HTML attr keeps its reactive getter; ref + the
|
|
171
|
+
// collapse-controlled style come last so they win (mergeProps is
|
|
172
|
+
// last-source-wins). The one-time `htmlProps.style` read above that
|
|
173
|
+
// seeds wrapperStyle is intentional: collapse OWNS the style prop
|
|
174
|
+
// (height/overflow are animation-driven), so a static merge of the
|
|
175
|
+
// user's initial style with the collapse overrides is correct here.
|
|
169
176
|
return h(
|
|
170
177
|
config.tag,
|
|
171
|
-
{ ref: wrapperRef,
|
|
178
|
+
mergeProps(htmlProps, { ref: wrapperRef, style: wrapperStyle }),
|
|
172
179
|
<Show when={shouldRender}>
|
|
173
180
|
<div ref={contentRef}>{children}</div>
|
|
174
181
|
</Show>,
|
|
@@ -140,7 +140,9 @@ const GroupRenderer = ({
|
|
|
140
140
|
)
|
|
141
141
|
})
|
|
142
142
|
|
|
143
|
-
|
|
143
|
+
// By reference — `{ ...htmlProps }` would value-copy and freeze any
|
|
144
|
+
// reactive HTML attr the kinetic split preserved as a getter.
|
|
145
|
+
return h(config.tag, htmlProps, ...groupedChildren)
|
|
144
146
|
}) as unknown as VNode
|
|
145
147
|
}
|
|
146
148
|
|
|
@@ -82,7 +82,11 @@ const StaggerRenderer = ({
|
|
|
82
82
|
)
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
// Pass htmlProps by reference — `{ ...htmlProps }` value-copies, firing
|
|
86
|
+
// any reactive getter the kinetic split preserved (frozen attr forever).
|
|
87
|
+
// runtime-dom's applyProps detects the getter descriptor on the live
|
|
88
|
+
// object and wraps it in renderEffect.
|
|
89
|
+
return h(config.tag, htmlProps, ...staggeredChildren)
|
|
86
90
|
}
|
|
87
91
|
|
|
88
92
|
export default StaggerRenderer
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { VNode } from '@pyreon/core'
|
|
2
|
-
import { createRef, h, Show } from '@pyreon/core'
|
|
2
|
+
import { createRef, 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'
|
|
@@ -139,19 +139,28 @@ const TransitionRenderer = (props: TransitionRendererProps): VNode | null => {
|
|
|
139
139
|
? null
|
|
140
140
|
: h(
|
|
141
141
|
props.config.tag,
|
|
142
|
-
|
|
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, {
|
|
143
147
|
ref: mergedRef,
|
|
144
|
-
...props.htmlProps,
|
|
145
148
|
style: {
|
|
146
149
|
...((props.htmlProps.style as CSSProperties) ?? {}),
|
|
147
150
|
display: 'none',
|
|
148
151
|
},
|
|
149
|
-
},
|
|
152
|
+
}),
|
|
150
153
|
props.children,
|
|
151
154
|
)
|
|
152
155
|
}
|
|
153
156
|
>
|
|
154
|
-
{h(
|
|
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
|
+
)}
|
|
155
164
|
</Show>
|
|
156
165
|
)
|
|
157
166
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { VNode } from '@pyreon/core'
|
|
2
|
+
import { splitProps } from '@pyreon/core'
|
|
2
3
|
import type { CSSProperties, TransitionCallbacks } from '../types'
|
|
3
4
|
import CollapseRenderer from './CollapseRenderer'
|
|
4
5
|
import GroupRenderer from './GroupRenderer'
|
|
@@ -30,17 +31,25 @@ const createKineticComponent = <Tag extends string, Mode extends KineticMode = '
|
|
|
30
31
|
config: KineticConfig,
|
|
31
32
|
): KineticComponent<Tag, Mode> => {
|
|
32
33
|
const Component = (props: Record<string, unknown>): VNode | null => {
|
|
33
|
-
// Separate kinetic-specific props from HTML pass-through props
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
34
|
+
// Separate kinetic-specific props from HTML pass-through props.
|
|
35
|
+
// MUST use splitProps (descriptor-preserving) — a plain
|
|
36
|
+
// `htmlProps[key] = props[key]` value-copy fires every getter at
|
|
37
|
+
// component-setup time. The compiler emits `<KineticDiv class={sig()}>`
|
|
38
|
+
// as `_rp(() => sig())`, which `makeReactiveProps` turns into a getter
|
|
39
|
+
// on `props`; reading it here (outside any tracking scope) would
|
|
40
|
+
// collapse it to a static snapshot and freeze the HTML attr forever.
|
|
41
|
+
// splitProps copies DESCRIPTORS via Object.getOwnPropertyDescriptor +
|
|
42
|
+
// Object.defineProperty, so the getter survives to the renderer's
|
|
43
|
+
// `h(config.tag, htmlProps)` where runtime-dom's applyProps detects
|
|
44
|
+
// the descriptor and wraps the read in renderEffect.
|
|
45
|
+
// `props` is `Record<string, unknown>`, so `Omit<…, string>` collapses
|
|
46
|
+
// to `{}` at the type level — the runtime split is correct (splitProps
|
|
47
|
+
// copies descriptors for every own key not in the pick set), only the
|
|
48
|
+
// inferred result types degrade. Cast back to the real shape.
|
|
49
|
+
const [kineticProps, htmlPropsWithChildren] = splitProps(props, [...KINETIC_KEYS]) as [
|
|
50
|
+
Record<string, unknown>,
|
|
51
|
+
Record<string, unknown>,
|
|
52
|
+
]
|
|
44
53
|
|
|
45
54
|
const {
|
|
46
55
|
show,
|
|
@@ -71,8 +80,12 @@ const createKineticComponent = <Tag extends string, Mode extends KineticMode = '
|
|
|
71
80
|
onAfterLeave: onAfterLeave ?? config.onAfterLeave,
|
|
72
81
|
}
|
|
73
82
|
|
|
74
|
-
//
|
|
75
|
-
|
|
83
|
+
// Carve `children` out of the HTML pass-through set — also via
|
|
84
|
+
// splitProps so the remaining HTML attrs keep their getter
|
|
85
|
+
// descriptors (`const { children, ...restHtml } = …` is the same
|
|
86
|
+
// value-copy footgun as the split above).
|
|
87
|
+
const [childHolder, restHtml] = splitProps(htmlPropsWithChildren, ['children'])
|
|
88
|
+
const children = childHolder.children
|
|
76
89
|
|
|
77
90
|
if (config.mode === 'collapse') {
|
|
78
91
|
return (
|