@pyreon/kinetic 0.20.0 → 0.22.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 +40 -8
- package/package.json +10 -9
- package/src/Transition.tsx +91 -18
- package/src/__tests__/Collapse.test.tsx +25 -4
- package/src/__tests__/Transition.ssr.test.tsx +183 -0
- package/src/__tests__/kinetic-modes.ssr.test.tsx +214 -0
- package/src/__tests__/kinetic.browser.test.tsx +180 -0
- package/src/kinetic/CollapseRenderer.tsx +34 -3
- package/src/kinetic/TransitionItem.tsx +70 -20
- package/src/kinetic/TransitionRenderer.tsx +95 -33
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, mergeProps, onMount, onUnmount, splitProps } from "@pyreon/core";
|
|
1
|
+
import { Show, createRef, cx, 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,16 +172,20 @@ 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
|
-
|
|
176
|
-
ref: wrapperRef,
|
|
177
|
-
style: wrapperStyle
|
|
178
|
-
}), /* @__PURE__ */ jsx(Show, {
|
|
175
|
+
const innerContent = show() ? /* @__PURE__ */ jsx(Show, {
|
|
179
176
|
when: shouldRender,
|
|
180
177
|
children: /* @__PURE__ */ jsx("div", {
|
|
181
178
|
ref: contentRef,
|
|
182
179
|
children
|
|
183
180
|
})
|
|
184
|
-
})
|
|
181
|
+
}) : /* @__PURE__ */ jsx("div", {
|
|
182
|
+
ref: contentRef,
|
|
183
|
+
children
|
|
184
|
+
});
|
|
185
|
+
return h(config.tag, mergeProps(htmlProps, {
|
|
186
|
+
ref: wrapperRef,
|
|
187
|
+
style: wrapperStyle
|
|
188
|
+
}), innerContent);
|
|
185
189
|
};
|
|
186
190
|
|
|
187
191
|
//#endregion
|
|
@@ -290,6 +294,9 @@ const cloneVNode = (vnode, extraProps) => ({
|
|
|
290
294
|
//#endregion
|
|
291
295
|
//#region src/kinetic/TransitionItem.tsx
|
|
292
296
|
const applyEnter$1 = (el, config) => {
|
|
297
|
+
removeClasses(el, config.leave);
|
|
298
|
+
removeClasses(el, config.leaveFrom);
|
|
299
|
+
removeClasses(el, config.leaveTo);
|
|
293
300
|
addClasses(el, config.enter);
|
|
294
301
|
addClasses(el, config.enterFrom);
|
|
295
302
|
if (config.enterStyle) Object.assign(el.style, config.enterStyle);
|
|
@@ -393,7 +400,7 @@ const TransitionItem = (props) => {
|
|
|
393
400
|
el.style.transition = "";
|
|
394
401
|
}
|
|
395
402
|
}, { immediate: true });
|
|
396
|
-
return /* @__PURE__ */ jsx(Show, {
|
|
403
|
+
if (props.show()) return /* @__PURE__ */ jsx(Show, {
|
|
397
404
|
when: shouldMount,
|
|
398
405
|
fallback: unmount ? null : cloneVNode(props.children, {
|
|
399
406
|
ref: mergedRef,
|
|
@@ -401,6 +408,16 @@ const TransitionItem = (props) => {
|
|
|
401
408
|
}),
|
|
402
409
|
children: cloneVNode(props.children, { ref: mergedRef })
|
|
403
410
|
});
|
|
411
|
+
const hiddenClass = props.leaveTo ?? props.enterFrom;
|
|
412
|
+
const hiddenStyle = props.leaveToStyle ?? props.enterStyle;
|
|
413
|
+
const childProps = props.children.props ?? {};
|
|
414
|
+
const childClass = childProps.class;
|
|
415
|
+
const mergedClass = hiddenClass ? cx([childClass, hiddenClass]) : void 0;
|
|
416
|
+
const mergedStyle = mergeStyles(childProps.style, hiddenStyle);
|
|
417
|
+
const extra = { ref: mergedRef };
|
|
418
|
+
if (mergedClass !== void 0) extra.class = mergedClass;
|
|
419
|
+
if (mergedStyle !== void 0) extra.style = mergedStyle;
|
|
420
|
+
return cloneVNode(props.children, extra);
|
|
404
421
|
};
|
|
405
422
|
|
|
406
423
|
//#endregion
|
|
@@ -531,6 +548,9 @@ const StaggerRenderer = ({ config, htmlProps, show, appear, timeout, interval, r
|
|
|
531
548
|
//#endregion
|
|
532
549
|
//#region src/kinetic/TransitionRenderer.tsx
|
|
533
550
|
const applyEnter = (el, config) => {
|
|
551
|
+
removeClasses(el, config.leave);
|
|
552
|
+
removeClasses(el, config.leaveFrom);
|
|
553
|
+
removeClasses(el, config.leaveTo);
|
|
534
554
|
addClasses(el, config.enter);
|
|
535
555
|
addClasses(el, config.enterFrom);
|
|
536
556
|
if (config.enterStyle) Object.assign(el.style, config.enterStyle);
|
|
@@ -610,7 +630,7 @@ const TransitionRenderer = (props) => {
|
|
|
610
630
|
el.style.transition = "";
|
|
611
631
|
}
|
|
612
632
|
}, { immediate: true });
|
|
613
|
-
return /* @__PURE__ */ jsx(Show, {
|
|
633
|
+
if (props.show()) return /* @__PURE__ */ jsx(Show, {
|
|
614
634
|
when: shouldMount,
|
|
615
635
|
fallback: effectiveUnmount ? null : h(props.config.tag, mergeProps(props.htmlProps, {
|
|
616
636
|
ref: mergedRef,
|
|
@@ -621,6 +641,18 @@ const TransitionRenderer = (props) => {
|
|
|
621
641
|
}), props.children),
|
|
622
642
|
children: h(props.config.tag, mergeProps(props.htmlProps, { ref: mergedRef }), props.children)
|
|
623
643
|
});
|
|
644
|
+
const hiddenClass = props.config.leaveTo ?? props.config.enterFrom;
|
|
645
|
+
const hiddenStyle = props.config.leaveToStyle ?? props.config.enterStyle;
|
|
646
|
+
const childClass = props.htmlProps.class;
|
|
647
|
+
const mergedClass = hiddenClass ? cx([childClass, hiddenClass]) : void 0;
|
|
648
|
+
const mergedStyle = hiddenStyle ? {
|
|
649
|
+
...props.htmlProps.style ?? {},
|
|
650
|
+
...hiddenStyle
|
|
651
|
+
} : void 0;
|
|
652
|
+
const extra = { ref: mergedRef };
|
|
653
|
+
if (mergedClass !== void 0) extra.class = mergedClass;
|
|
654
|
+
if (mergedStyle !== void 0) extra.style = mergedStyle;
|
|
655
|
+
return h(props.config.tag, mergeProps(props.htmlProps, extra), props.children);
|
|
624
656
|
};
|
|
625
657
|
|
|
626
658
|
//#endregion
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/kinetic",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.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.22.0",
|
|
46
|
+
"@pyreon/reactivity": "^0.22.0",
|
|
47
|
+
"@pyreon/runtime-dom": "^0.22.0",
|
|
48
|
+
"@pyreon/runtime-server": "^0.22.0",
|
|
49
|
+
"@pyreon/test-utils": "^0.13.9",
|
|
50
|
+
"@pyreon/typescript": "^0.22.0",
|
|
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.22.0",
|
|
59
|
+
"@pyreon/reactivity": "^0.22.0",
|
|
60
|
+
"@pyreon/runtime-dom": "^0.22.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,83 @@ 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
|
+
// Picker mirrors what #719 introduced for the kinetic(tag).<mode>
|
|
236
|
+
// renderers (TransitionRenderer / TransitionItem / CollapseRenderer):
|
|
237
|
+
// prefer leave-end state, fall back to pre-enter state. The
|
|
238
|
+
// `enterStyle` fallback covers the preset path — `@pyreon/kinetic-presets`
|
|
239
|
+
// factories (fadeUp, blurInUp, slideLeft, …) populate `enterStyle` as
|
|
240
|
+
// the hidden state but may not set `leaveToStyle`. Without this
|
|
241
|
+
// fallback, preset users SSR-render VISIBLE → flash-on-hydration.
|
|
242
|
+
// (PR #717 shipped this branch with `leaveToStyle` alone; the class
|
|
243
|
+
// picker already had the `enterFrom` fallback. This commit aligns the
|
|
244
|
+
// style picker so both halves match.)
|
|
245
|
+
const hiddenClass = props.leaveTo ?? props.enterFrom
|
|
246
|
+
const hiddenStyle = props.leaveToStyle ?? props.enterStyle
|
|
247
|
+
const childClass = childProps.class
|
|
248
|
+
const mergedClass = hiddenClass
|
|
249
|
+
? cx([childClass as Parameters<typeof cx>[0], hiddenClass])
|
|
250
|
+
: undefined
|
|
251
|
+
const mergedStyle = mergeStyles(
|
|
252
|
+
childProps.style as Record<string, string | number | undefined> | undefined,
|
|
253
|
+
hiddenStyle,
|
|
190
254
|
)
|
|
255
|
+
|
|
256
|
+
// Build extra-props carefully — undefined values must NOT be passed to
|
|
257
|
+
// cloneVNode because `{...vnode.props, ...extraProps}` spreads them and
|
|
258
|
+
// overrides any user-set `class`/`style` on the child vnode with undefined.
|
|
259
|
+
const extra: Record<string, unknown> = { ref: mergedRef }
|
|
260
|
+
if (mergedClass !== undefined) extra.class = mergedClass
|
|
261
|
+
if (mergedStyle !== undefined) extra.style = mergedStyle
|
|
262
|
+
|
|
263
|
+
return cloneVNode(props.children, extra)
|
|
191
264
|
}
|
|
192
265
|
|
|
193
266
|
export default Transition
|
|
@@ -397,14 +397,35 @@ const wireWrapperRef = (vnode: VNode | null, el: HTMLElement) => {
|
|
|
397
397
|
}
|
|
398
398
|
}
|
|
399
399
|
|
|
400
|
-
/**
|
|
400
|
+
/**
|
|
401
|
+
* Find and wire contentRef on the inner div. Walks two shapes:
|
|
402
|
+
*
|
|
403
|
+
* 1. Initially-visible Collapse: outer → <Show>{<div ref=contentRef/>}</Show>
|
|
404
|
+
* (the pre-SSR-fix shape, kept for the `wasInitiallyShown=true` branch
|
|
405
|
+
* in CollapseRenderer)
|
|
406
|
+
*
|
|
407
|
+
* 2. Initially-hidden Collapse: outer → <div ref=contentRef/>
|
|
408
|
+
* (the SSR-correct shape — children always rendered structurally so
|
|
409
|
+
* the prerendered HTML carries content for SEO / social scrapers /
|
|
410
|
+
* no-JS users; visual hiding via the outer wrapper's `height: 0;
|
|
411
|
+
* overflow: hidden`. See CollapseRenderer's `wasInitiallyShown`
|
|
412
|
+
* branch.)
|
|
413
|
+
*
|
|
414
|
+
* Tries the direct-div shape first; falls back to the Show-wrapped walk.
|
|
415
|
+
*/
|
|
401
416
|
const wireContentRef = (vnode: VNode | null, contentEl: HTMLElement) => {
|
|
402
417
|
if (!vnode?.children) return
|
|
403
418
|
const vnodeChildren = Array.isArray(vnode.children) ? vnode.children : [vnode.children]
|
|
404
419
|
for (const c of vnodeChildren) {
|
|
405
|
-
if (!c || typeof c !== 'object' || !('
|
|
406
|
-
const
|
|
407
|
-
|
|
420
|
+
if (!c || typeof c !== 'object' || !('props' in (c as object))) continue
|
|
421
|
+
const directRef = (c as any).props?.ref
|
|
422
|
+
if (directRef) {
|
|
423
|
+
if (typeof directRef === 'function') directRef(contentEl)
|
|
424
|
+
else if (typeof directRef === 'object') directRef.current = contentEl
|
|
425
|
+
return
|
|
426
|
+
}
|
|
427
|
+
// Fall through to Show-wrapped walk.
|
|
428
|
+
const showChildren = (c as any).props?.children ?? (c as any).children
|
|
408
429
|
if (!showChildren) continue
|
|
409
430
|
const sc = Array.isArray(showChildren) ? showChildren : [showChildren]
|
|
410
431
|
for (const s of sc) {
|
|
@@ -0,0 +1,183 @@
|
|
|
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('falls back to `enterStyle` as hidden style when leaveToStyle undefined (preset path)', async () => {
|
|
115
|
+
// The preset shape — `@pyreon/kinetic-presets` factories (fadeUp,
|
|
116
|
+
// blurInUp, slideLeft, …) populate `enterStyle` as the hidden state
|
|
117
|
+
// but may not set `leaveToStyle`. PR #717 shipped the
|
|
118
|
+
// `wasInitiallyShown` branch with `hiddenStyle = props.leaveToStyle`
|
|
119
|
+
// alone — so preset users SSR-rendered VISIBLE → flash-on-hydration.
|
|
120
|
+
// This regression test locks in the `?? props.enterStyle` fallback
|
|
121
|
+
// that aligns the style picker with the existing
|
|
122
|
+
// `hiddenClass = leaveTo ?? enterFrom` class picker.
|
|
123
|
+
//
|
|
124
|
+
// The companion `kinetic(tag).<mode>` paths (TransitionRenderer /
|
|
125
|
+
// TransitionItem / CollapseRenderer) got the same fallback in #719;
|
|
126
|
+
// this commit closes the matching gap on the direct `<Transition>`
|
|
127
|
+
// import path.
|
|
128
|
+
const html = await renderToString(
|
|
129
|
+
h(Transition, {
|
|
130
|
+
show: () => false,
|
|
131
|
+
enter: 'transition-all duration-300',
|
|
132
|
+
enterStyle: { opacity: 0, transform: 'translateY(16px)' },
|
|
133
|
+
enterToStyle: { opacity: 1, transform: 'translateY(0)' },
|
|
134
|
+
children: h('section', null, 'preset-shaped hidden state'),
|
|
135
|
+
}),
|
|
136
|
+
)
|
|
137
|
+
expect(html).toContain('preset-shaped hidden state')
|
|
138
|
+
expect(html).toContain('opacity: 0')
|
|
139
|
+
expect(html).toContain('translateY(16px)')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('merges the hidden class with any user-set class on the child', async () => {
|
|
143
|
+
const html = await renderToString(
|
|
144
|
+
h(Transition, {
|
|
145
|
+
show: () => false,
|
|
146
|
+
leaveTo: 'is-hidden',
|
|
147
|
+
children: h('div', { class: 'card card--featured' }, 'merged-class content'),
|
|
148
|
+
}),
|
|
149
|
+
)
|
|
150
|
+
expect(html).toContain('merged-class content')
|
|
151
|
+
expect(html).toContain('card')
|
|
152
|
+
expect(html).toContain('card--featured')
|
|
153
|
+
expect(html).toContain('is-hidden')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('emits children unchanged when neither leaveTo nor enterFrom is defined (graceful no-op)', async () => {
|
|
157
|
+
// An unusual config — no enter/leave classes at all. Children should
|
|
158
|
+
// still render structurally (the SEO/SSG contract); no hidden class
|
|
159
|
+
// is appended because there's nothing to append.
|
|
160
|
+
const html = await renderToString(
|
|
161
|
+
h(Transition, {
|
|
162
|
+
show: () => false,
|
|
163
|
+
children: h('div', null, 'bare content'),
|
|
164
|
+
}),
|
|
165
|
+
)
|
|
166
|
+
expect(html).toContain('bare content')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('initially-visible Transition (show=true) renders children normally — unchanged behavior', async () => {
|
|
170
|
+
// The other branch of the fix — initially-visible Transitions keep
|
|
171
|
+
// the original `<Show>`-gated path. This spec locks in the no-
|
|
172
|
+
// regression contract for the existing common case.
|
|
173
|
+
const html = await renderToString(
|
|
174
|
+
h(Transition, {
|
|
175
|
+
show: () => true,
|
|
176
|
+
leaveTo: 'is-hidden', // must NOT leak onto initially-visible
|
|
177
|
+
children: h('main', null, 'visible from the start'),
|
|
178
|
+
}),
|
|
179
|
+
)
|
|
180
|
+
expect(html).toContain('visible from the start')
|
|
181
|
+
expect(html).not.toContain('is-hidden')
|
|
182
|
+
})
|
|
183
|
+
})
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSR regression coverage for the `kinetic(tag).<mode>` API — the three
|
|
3
|
+
* renderer files PR #717 didn't reach:
|
|
4
|
+
*
|
|
5
|
+
* - `TransitionRenderer` → `kinetic('div').preset(...)` (default
|
|
6
|
+
* `.transition` mode — the README's main example)
|
|
7
|
+
* - `TransitionItem` → `kinetic('ul').stagger()` per item (the
|
|
8
|
+
* cascade-children mode); transitively `kinetic('ul').group()`
|
|
9
|
+
* - `CollapseRenderer` → `kinetic('div').collapse()` (height-animation
|
|
10
|
+
* mode)
|
|
11
|
+
*
|
|
12
|
+
* Background. The top-level `<Transition>` was fixed in PR #717. But the
|
|
13
|
+
* `kinetic(tag).<mode>` API — which the README promotes as the primary
|
|
14
|
+
* surface — has its own per-mode renderers, and all three carried the
|
|
15
|
+
* SAME `<Show when={shouldMount} fallback={null}>` shape, dropping
|
|
16
|
+
* children from prerendered HTML when `show()` is false at SSR. That
|
|
17
|
+
* meant every documented `kinetic(tag).<mode>` consumer hit the bug
|
|
18
|
+
* even after #717 landed — including the cascading-Stagger pattern this
|
|
19
|
+
* report's author flagged on a real resume page.
|
|
20
|
+
*
|
|
21
|
+
* The fix mirrors #717: branch each renderer at setup on `props.show()`.
|
|
22
|
+
* Initially-visible → existing `<Show>`-gated mount (preserves runtime-
|
|
23
|
+
* unmount semantic). Initially-hidden → always render the inner content
|
|
24
|
+
* with the hidden-state class/style inlined; the existing `watch(stage)`
|
|
25
|
+
* effect drives the enter animation when `show` flips true.
|
|
26
|
+
*
|
|
27
|
+
* Hidden-state picker (mirrors #717): `leaveTo` / `leaveToStyle` win
|
|
28
|
+
* (explicit hidden-end state); fall back to `enterFrom` / `enterStyle`
|
|
29
|
+
* (pre-enter state). The `enterStyle` fallback covers the preset path —
|
|
30
|
+
* `@pyreon/kinetic-presets` factories populate `enterStyle` as the
|
|
31
|
+
* hidden state but may not set `leaveToStyle`. Without the fallback,
|
|
32
|
+
* preset users would SSR-render VISIBLE → flash-on-hydration.
|
|
33
|
+
*
|
|
34
|
+
* API note. `kinetic(tag)` takes animation config via CHAIN methods
|
|
35
|
+
* (`.enter()`, `.enterClass({from, to, active})`, `.leaveClass(...)`,
|
|
36
|
+
* `.preset()`), NOT as runtime props. Runtime props are limited to
|
|
37
|
+
* `show` / `appear` / `unmount` / `timeout` plus HTML attributes —
|
|
38
|
+
* anything else gets forwarded to the rendered element. The tests
|
|
39
|
+
* below use the chain API to faithfully exercise real user code.
|
|
40
|
+
*
|
|
41
|
+
* Coverage layered with PR #717: the test file there
|
|
42
|
+
* (`Transition.ssr.test.tsx`) covers the direct `<Transition>` import
|
|
43
|
+
* path; this file covers the `kinetic(tag).<mode>` factory paths.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { h } from '@pyreon/core'
|
|
47
|
+
import { renderToString } from '@pyreon/runtime-server'
|
|
48
|
+
import { describe, expect, it } from 'vitest'
|
|
49
|
+
import kinetic from '../kinetic'
|
|
50
|
+
|
|
51
|
+
describe('kinetic(tag).transition — SSR / initially-hidden (TransitionRenderer)', () => {
|
|
52
|
+
it('emits children when show=false initially (kinetic-mode shape, was: empty wrapper)', async () => {
|
|
53
|
+
// Cascading-bug shape — every `kinetic('div').preset(...)` user with a
|
|
54
|
+
// scroll-reveal `show` accessor hit this. Pre-fix: outer wrapper renders
|
|
55
|
+
// but children are dropped by the inner Show fallback.
|
|
56
|
+
const FadeSection = kinetic('section').enterClass({
|
|
57
|
+
active: 'transition-all duration-300',
|
|
58
|
+
from: 'opacity-0',
|
|
59
|
+
to: 'opacity-100',
|
|
60
|
+
})
|
|
61
|
+
const html = await renderToString(
|
|
62
|
+
h(FadeSection, { show: () => false },
|
|
63
|
+
h('h2', null, 'Work Experience'),
|
|
64
|
+
h('p', null, 'real content for SEO + social scrapers'),
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
expect(html).toContain('<h2')
|
|
68
|
+
expect(html).toContain('Work Experience')
|
|
69
|
+
expect(html).toContain('real content for SEO + social scrapers')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('inlines `leaveTo` class over `enterFrom` (explicit hidden-end state wins)', async () => {
|
|
73
|
+
const Panel = kinetic('aside')
|
|
74
|
+
.enterClass({ from: 'translate-y-4', to: 'translate-y-0' })
|
|
75
|
+
.leaveClass({ to: 'is-hidden opacity-0' })
|
|
76
|
+
const html = await renderToString(
|
|
77
|
+
h(Panel, { show: () => false },
|
|
78
|
+
h('div', null, 'panel content'),
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
expect(html).toContain('is-hidden opacity-0')
|
|
82
|
+
expect(html).toContain('panel content')
|
|
83
|
+
// leaveTo wins — the competing enterFrom should NOT be applied.
|
|
84
|
+
expect(html).not.toContain('translate-y-4')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('inlines `enterStyle` as hidden style when leaveToStyle undefined (preset path)', async () => {
|
|
88
|
+
// The preset shape — `@pyreon/kinetic-presets` factories populate
|
|
89
|
+
// `enterStyle` (= `.enter()` chain) as the hidden state. Without the
|
|
90
|
+
// enterStyle fallback, SSR would render VISIBLE → flash-on-hydration.
|
|
91
|
+
// This locks in the critical preset-compatibility behaviour.
|
|
92
|
+
const FadeUpDiv = kinetic('div')
|
|
93
|
+
.enter({ opacity: 0, transform: 'translateY(16px)' })
|
|
94
|
+
.enterTo({ opacity: 1, transform: 'translateY(0)' })
|
|
95
|
+
.enterTransition('all 300ms ease-out')
|
|
96
|
+
const html = await renderToString(
|
|
97
|
+
h(FadeUpDiv, { show: () => false },
|
|
98
|
+
h('h1', null, 'preset-shaped hidden state'),
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
expect(html).toContain('preset-shaped hidden state')
|
|
102
|
+
expect(html).toContain('opacity: 0')
|
|
103
|
+
expect(html).toContain('translateY(16px)')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('initially-visible (show=true) renders normally — unchanged behaviour', async () => {
|
|
107
|
+
const FadeDiv = kinetic('div').leaveClass({ to: 'is-hidden' })
|
|
108
|
+
const html = await renderToString(
|
|
109
|
+
h(FadeDiv, { show: () => true },
|
|
110
|
+
h('main', null, 'visible from the start'),
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
expect(html).toContain('visible from the start')
|
|
114
|
+
// leaveTo must NOT leak onto the initially-visible render.
|
|
115
|
+
expect(html).not.toContain('is-hidden')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('falls back to `enterFrom` class for scroll-reveal patterns (only enter side configured)', async () => {
|
|
119
|
+
const RevealSection = kinetic('section').enterClass({
|
|
120
|
+
active: 'transition-all duration-700',
|
|
121
|
+
from: 'opacity-0 translate-y-8',
|
|
122
|
+
to: 'opacity-100 translate-y-0',
|
|
123
|
+
})
|
|
124
|
+
const html = await renderToString(
|
|
125
|
+
h(RevealSection, { show: () => false, id: 'resume-section' },
|
|
126
|
+
h('p', null, 'work history goes here'),
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
expect(html).toContain('id="resume-section"')
|
|
130
|
+
expect(html).toContain('work history goes here')
|
|
131
|
+
expect(html).toContain('opacity-0 translate-y-8')
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('kinetic(tag).stagger() — SSR / initially-hidden (TransitionItem per item)', () => {
|
|
136
|
+
it('emits all child items when show=false initially (cascading stagger SSR shape)', async () => {
|
|
137
|
+
// The reported real-app pattern: cascading intro / list reveal.
|
|
138
|
+
// Pre-fix: every per-item TransitionItem rendered null on the server,
|
|
139
|
+
// dropping the full list from prerendered HTML.
|
|
140
|
+
const StaggerList = kinetic('ul')
|
|
141
|
+
.enterClass({
|
|
142
|
+
active: 'transition-all',
|
|
143
|
+
from: 'opacity-0 translate-y-4',
|
|
144
|
+
to: 'opacity-100 translate-y-0',
|
|
145
|
+
})
|
|
146
|
+
.stagger({ interval: 80 })
|
|
147
|
+
const html = await renderToString(
|
|
148
|
+
h(StaggerList, { show: () => false },
|
|
149
|
+
[
|
|
150
|
+
h('li', { key: 'h' }, 'Heading'),
|
|
151
|
+
h('li', { key: 't' }, 'tagline content'),
|
|
152
|
+
h('li', { key: 's' }, 'social icons row'),
|
|
153
|
+
],
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
expect(html).toContain('Heading')
|
|
157
|
+
expect(html).toContain('tagline content')
|
|
158
|
+
expect(html).toContain('social icons row')
|
|
159
|
+
// Every per-item TransitionItem should apply the hidden class
|
|
160
|
+
// (enterFrom in this scroll-reveal shape).
|
|
161
|
+
const occurrences = (html.match(/opacity-0 translate-y-4/g) ?? []).length
|
|
162
|
+
expect(occurrences).toBeGreaterThanOrEqual(3)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('initially-visible stagger (show=true) renders all items unchanged', async () => {
|
|
166
|
+
const StaggerList = kinetic('ul')
|
|
167
|
+
.enterClass({ from: 'opacity-0', to: 'opacity-100' })
|
|
168
|
+
.leaveClass({ to: 'is-hidden' })
|
|
169
|
+
.stagger({ interval: 50 })
|
|
170
|
+
const html = await renderToString(
|
|
171
|
+
h(StaggerList, { show: () => true },
|
|
172
|
+
[h('li', { key: 'a' }, 'item-a'), h('li', { key: 'b' }, 'item-b')],
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
expect(html).toContain('item-a')
|
|
176
|
+
expect(html).toContain('item-b')
|
|
177
|
+
// leaveTo must NOT leak onto visible items.
|
|
178
|
+
expect(html).not.toContain('is-hidden')
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('kinetic(tag).collapse() — SSR / initially-hidden (CollapseRenderer)', () => {
|
|
183
|
+
it('emits inner content when show=false initially (was: empty 0-height wrapper)', async () => {
|
|
184
|
+
// Pre-fix: outer wrapper renders with `height: 0; overflow: hidden`
|
|
185
|
+
// but its children are stripped by the inner Show — empty wrapper in
|
|
186
|
+
// prerendered HTML. The fix keeps the outer wrapper's visual hiding
|
|
187
|
+
// (height: 0 IS the layout-safe collapse mechanism — flex slots see
|
|
188
|
+
// a 0-height box, no slot-collapse) while always rendering inner content.
|
|
189
|
+
const Accordion = kinetic('div').collapse()
|
|
190
|
+
const html = await renderToString(
|
|
191
|
+
h(Accordion, { show: () => false },
|
|
192
|
+
h('div', { class: 'panel-body' }, 'accordion panel content for SEO'),
|
|
193
|
+
),
|
|
194
|
+
)
|
|
195
|
+
expect(html).toContain('accordion panel content for SEO')
|
|
196
|
+
expect(html).toContain('panel-body')
|
|
197
|
+
// The outer wrapper retains the collapse-controlled hidden style —
|
|
198
|
+
// visual hiding via height:0 + overflow:hidden, not by dropping children.
|
|
199
|
+
expect(html).toContain('height: 0px')
|
|
200
|
+
expect(html).toContain('overflow: hidden')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('initially-visible collapse (show=true) renders content normally', async () => {
|
|
204
|
+
const Accordion = kinetic('section').collapse()
|
|
205
|
+
const html = await renderToString(
|
|
206
|
+
h(Accordion, { show: () => true },
|
|
207
|
+
h('p', null, 'expanded content'),
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
expect(html).toContain('expanded content')
|
|
211
|
+
// height: 'auto' is the entered-state hint
|
|
212
|
+
expect(html).toContain('height: auto')
|
|
213
|
+
})
|
|
214
|
+
})
|
|
@@ -144,4 +144,184 @@ describe('@pyreon/kinetic browser smoke', () => {
|
|
|
144
144
|
// `"development" !== "production"` → `true` in dev runs.
|
|
145
145
|
expect(process.env.NODE_ENV).not.toBe('production')
|
|
146
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
|
+
})
|
|
214
|
+
|
|
215
|
+
// ── Initially-hidden kinetic(tag).<mode> — client-side parity with SSR ──
|
|
216
|
+
//
|
|
217
|
+
// Companion to PR #717's `<Transition>` direct-import specs (the two
|
|
218
|
+
// above). These exercise the `kinetic(tag).<mode>` factory paths — the
|
|
219
|
+
// README's primary documented surface — whose per-mode renderers carried
|
|
220
|
+
// the same SSR-children-dropped bug until this PR fixed them. SSR specs
|
|
221
|
+
// in `kinetic-modes.ssr.test.tsx` prove children land in prerendered
|
|
222
|
+
// HTML; these specs prove the SAME render path works under a real DOM —
|
|
223
|
+
// the element mounts with the hidden-state class/style applied, and an
|
|
224
|
+
// `applyEnter` triggered by a `show` flip cleanly transitions it out.
|
|
225
|
+
|
|
226
|
+
it('kinetic("div").transition with initial show=false mounts element with hidden class', async () => {
|
|
227
|
+
const Reveal = kinetic('section').enterClass({
|
|
228
|
+
active: 'enter-active',
|
|
229
|
+
from: 'hide-state',
|
|
230
|
+
to: 'show-state',
|
|
231
|
+
})
|
|
232
|
+
const show = signal(false)
|
|
233
|
+
const { container, unmount } = mountInBrowser(
|
|
234
|
+
h(Reveal, { show, 'data-id': 'reveal-target' }, h('p', null, 'scroll-reveal content')),
|
|
235
|
+
)
|
|
236
|
+
// Pre-fix: container.querySelector returns null (children dropped).
|
|
237
|
+
const el = container.querySelector('[data-id="reveal-target"]') as HTMLElement | null
|
|
238
|
+
expect(el).not.toBeNull()
|
|
239
|
+
expect(el!.textContent).toContain('scroll-reveal content')
|
|
240
|
+
// enterFrom is the fallback hidden-state class (scroll-reveal pattern
|
|
241
|
+
// configures only the enter side).
|
|
242
|
+
expect(el!.classList.contains('hide-state')).toBe(true)
|
|
243
|
+
unmount()
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('kinetic("div").transition show=true flip cleans hidden class + runs enter animation', async () => {
|
|
247
|
+
const Reveal = kinetic('section').enterClass({
|
|
248
|
+
active: 'enter-active',
|
|
249
|
+
from: 'hide-state',
|
|
250
|
+
to: 'show-state',
|
|
251
|
+
})
|
|
252
|
+
const show = signal(false)
|
|
253
|
+
const { container, unmount } = mountInBrowser(
|
|
254
|
+
h(Reveal, { show, 'data-id': 'reveal-target' }, h('p', null, 'content')),
|
|
255
|
+
)
|
|
256
|
+
const el = () => container.querySelector('[data-id="reveal-target"]') as HTMLElement | null
|
|
257
|
+
expect(el()!.classList.contains('hide-state')).toBe(true)
|
|
258
|
+
|
|
259
|
+
show.set(true)
|
|
260
|
+
await flush()
|
|
261
|
+
// Double-rAF for the applyEnter nextFrame → enterTo applied.
|
|
262
|
+
await new Promise<void>((resolve) =>
|
|
263
|
+
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
|
264
|
+
)
|
|
265
|
+
await flush()
|
|
266
|
+
|
|
267
|
+
expect(el()!.classList.contains('show-state')).toBe(true)
|
|
268
|
+
// enterFrom (hide-state) was removed; the symmetric applyEnter cleanup
|
|
269
|
+
// ALSO removes leave-side classes (none here) — locks in the
|
|
270
|
+
// companion fix that prevents residual hidden classes from fighting
|
|
271
|
+
// enterTo's CSS rules.
|
|
272
|
+
expect(el()!.classList.contains('hide-state')).toBe(false)
|
|
273
|
+
expect(el()!.classList.contains('enter-active')).toBe(true)
|
|
274
|
+
unmount()
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('kinetic("ul").stagger() with initial show=false mounts all items with hidden class', async () => {
|
|
278
|
+
// The reported real-app cascading-Stagger pattern at SSR. Each per-item
|
|
279
|
+
// TransitionItem must render structurally; the hidden class lands on
|
|
280
|
+
// each item via the enterFrom fallback.
|
|
281
|
+
const Staggered = kinetic('ul')
|
|
282
|
+
.enterClass({ active: 'enter-active', from: 'item-hidden', to: 'item-shown' })
|
|
283
|
+
.stagger({ interval: 50 })
|
|
284
|
+
const show = signal(false)
|
|
285
|
+
const { container, unmount } = mountInBrowser(
|
|
286
|
+
h(Staggered, { show, 'data-id': 'stagger-list' }, [
|
|
287
|
+
h('li', { key: 'a' }, 'first item'),
|
|
288
|
+
h('li', { key: 'b' }, 'second item'),
|
|
289
|
+
h('li', { key: 'c' }, 'third item'),
|
|
290
|
+
]),
|
|
291
|
+
)
|
|
292
|
+
const list = container.querySelector('[data-id="stagger-list"]') as HTMLElement | null
|
|
293
|
+
expect(list).not.toBeNull()
|
|
294
|
+
const items = list!.querySelectorAll('li')
|
|
295
|
+
expect(items.length).toBe(3)
|
|
296
|
+
// Every per-item TransitionItem applies the hidden class.
|
|
297
|
+
for (const item of items) {
|
|
298
|
+
expect(item.classList.contains('item-hidden')).toBe(true)
|
|
299
|
+
}
|
|
300
|
+
expect(list!.textContent).toContain('first item')
|
|
301
|
+
expect(list!.textContent).toContain('second item')
|
|
302
|
+
expect(list!.textContent).toContain('third item')
|
|
303
|
+
unmount()
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('kinetic("div").collapse() with initial show=false mounts inner content (visually hidden via height:0)', async () => {
|
|
307
|
+
// CollapseRenderer's fix: outer wrapper retains height:0 + overflow:hidden
|
|
308
|
+
// (layout-safe visual hiding); inner content is always rendered so SSG
|
|
309
|
+
// ships the structural HTML for SEO. Real-DOM parity check.
|
|
310
|
+
const Accordion = kinetic('div').collapse()
|
|
311
|
+
const show = signal(false)
|
|
312
|
+
const { container, unmount } = mountInBrowser(
|
|
313
|
+
h(Accordion, { show, 'data-id': 'accordion' },
|
|
314
|
+
h('div', { 'data-id': 'inner' }, 'accordion content'),
|
|
315
|
+
),
|
|
316
|
+
)
|
|
317
|
+
const wrapper = container.querySelector('[data-id="accordion"]') as HTMLElement | null
|
|
318
|
+
const inner = container.querySelector('[data-id="inner"]') as HTMLElement | null
|
|
319
|
+
expect(wrapper).not.toBeNull()
|
|
320
|
+
expect(inner).not.toBeNull() // ← was null pre-fix (Show dropped it)
|
|
321
|
+
expect(inner!.textContent).toBe('accordion content')
|
|
322
|
+
// Outer wrapper visually hides via height:0 (computed style — real CSS).
|
|
323
|
+
expect(wrapper!.style.height).toBe('0px')
|
|
324
|
+
expect(wrapper!.style.overflow).toBe('hidden')
|
|
325
|
+
unmount()
|
|
326
|
+
})
|
|
147
327
|
})
|
|
@@ -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,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, TransitionCallbacks } from '../types'
|
|
5
5
|
import useAnimationEnd from '../useAnimationEnd'
|
|
@@ -19,6 +19,14 @@ type TransitionItemProps = ClassTransitionProps &
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
const applyEnter = (el: HTMLElement, config: ClassTransitionProps & StyleTransitionProps) => {
|
|
22
|
+
// Symmetric to applyLeave: clear residual leave-cycle classes — including
|
|
23
|
+
// the `leaveTo`/`enterFrom` class the SSR/initially-hidden render path
|
|
24
|
+
// inlines (see the `wasInitiallyShown` branch below). Without this, the
|
|
25
|
+
// SSR-baked hidden class would compete with `enterTo`'s CSS rules.
|
|
26
|
+
removeClasses(el, config.leave)
|
|
27
|
+
removeClasses(el, config.leaveFrom)
|
|
28
|
+
removeClasses(el, config.leaveTo)
|
|
29
|
+
|
|
22
30
|
addClasses(el, config.enter)
|
|
23
31
|
addClasses(el, config.enterFrom)
|
|
24
32
|
if (config.enterStyle) Object.assign(el.style, config.enterStyle)
|
|
@@ -155,26 +163,68 @@ const TransitionItem = (props: TransitionItemProps): VNode | null => {
|
|
|
155
163
|
{ immediate: true },
|
|
156
164
|
)
|
|
157
165
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
166
|
+
// Initially-visible items keep the original Show-gated mount, preserving
|
|
167
|
+
// the documented runtime-unmount semantic for visible→hidden. The SSR
|
|
168
|
+
// bug (children dropped from prerendered HTML) only fires for the
|
|
169
|
+
// initially-HIDDEN case below, where `<Show when={false}>` renders `null`
|
|
170
|
+
// on the server. For Stagger/Group usage at SSR (when the parent's
|
|
171
|
+
// `show: () => false`), each per-item TransitionItem hit this and
|
|
172
|
+
// dropped its child — full list missing from prerendered HTML.
|
|
173
|
+
//
|
|
174
|
+
// Mirrors the fix in `<Transition>` (PR #717) and `TransitionRenderer`
|
|
175
|
+
// (same PR as this). Ecosystem norm: content is structural, animation
|
|
176
|
+
// is visual.
|
|
177
|
+
const wasInitiallyShown = props.show()
|
|
178
|
+
if (wasInitiallyShown) {
|
|
179
|
+
return (
|
|
180
|
+
<Show
|
|
181
|
+
when={shouldMount}
|
|
182
|
+
fallback={
|
|
183
|
+
unmount
|
|
184
|
+
? null
|
|
185
|
+
: cloneVNode(props.children, {
|
|
186
|
+
ref: mergedRef,
|
|
187
|
+
style: mergeStyles(
|
|
188
|
+
(props.children.props as Record<string, unknown>)?.style as
|
|
189
|
+
| Record<string, string | number | undefined>
|
|
190
|
+
| undefined,
|
|
191
|
+
{ display: 'none' },
|
|
192
|
+
),
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
>
|
|
196
|
+
{cloneVNode(props.children, { ref: mergedRef })}
|
|
197
|
+
</Show>
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Initially-hidden path — always emit the child with hidden-state class +
|
|
202
|
+
// style inlined. `leaveTo`/`leaveToStyle` (explicit hidden-end state)
|
|
203
|
+
// wins; falls back to `enterFrom`/`enterStyle` (pre-enter state — covers
|
|
204
|
+
// both class-based scroll-reveal AND the preset path, where
|
|
205
|
+
// `@pyreon/kinetic-presets` factories populate `enterStyle` as the
|
|
206
|
+
// hidden state but may not set `leaveToStyle`).
|
|
207
|
+
//
|
|
208
|
+
// Trade-off: for an initially-hidden item, `unmount: true` no longer
|
|
209
|
+
// triggers a true DOM removal after a later leave animation completes.
|
|
210
|
+
// Initially-visible items keep the unmount semantic.
|
|
211
|
+
const hiddenClass = props.leaveTo ?? props.enterFrom
|
|
212
|
+
const hiddenStyle = props.leaveToStyle ?? props.enterStyle
|
|
213
|
+
const childProps = (props.children.props ?? {}) as Record<string, unknown>
|
|
214
|
+
const childClass = childProps.class
|
|
215
|
+
const mergedClass = hiddenClass
|
|
216
|
+
? cx([childClass as Parameters<typeof cx>[0], hiddenClass])
|
|
217
|
+
: undefined
|
|
218
|
+
const mergedStyle = mergeStyles(
|
|
219
|
+
childProps.style as Record<string, string | number | undefined> | undefined,
|
|
220
|
+
hiddenStyle,
|
|
177
221
|
)
|
|
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 cloneVNode(props.children, extra)
|
|
178
228
|
}
|
|
179
229
|
|
|
180
230
|
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
|