@pyreon/kinetic 0.21.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/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
- return h(config.tag, mergeProps(htmlProps, {
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.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "CSS-transition-based animation components for Pyreon",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,12 +42,12 @@
42
42
  "typecheck": "tsc --noEmit"
43
43
  },
44
44
  "devDependencies": {
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",
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",
51
51
  "@vitest/browser-playwright": "^4.1.4",
52
52
  "@vitus-labs/tools-rolldown": "^2.3.0"
53
53
  },
@@ -55,8 +55,8 @@
55
55
  "node": ">= 22"
56
56
  },
57
57
  "dependencies": {
58
- "@pyreon/core": "^0.21.0",
59
- "@pyreon/reactivity": "^0.21.0",
60
- "@pyreon/runtime-dom": "^0.21.0"
58
+ "@pyreon/core": "^0.22.0",
59
+ "@pyreon/reactivity": "^0.22.0",
60
+ "@pyreon/runtime-dom": "^0.22.0"
61
61
  }
62
62
  }
@@ -232,8 +232,18 @@ const Transition = (props: TransitionProps): VNode | null => {
232
232
  // The `watch(stage)` effect above drives the enter animation when
233
233
  // `show` flips true; `applyEnter` (above) clears these residual
234
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.)
235
245
  const hiddenClass = props.leaveTo ?? props.enterFrom
236
- const hiddenStyle = props.leaveToStyle
246
+ const hiddenStyle = props.leaveToStyle ?? props.enterStyle
237
247
  const childClass = childProps.class
238
248
  const mergedClass = hiddenClass
239
249
  ? cx([childClass as Parameters<typeof cx>[0], hiddenClass])
@@ -397,14 +397,35 @@ const wireWrapperRef = (vnode: VNode | null, el: HTMLElement) => {
397
397
  }
398
398
  }
399
399
 
400
- /** Find and wire contentRef inside Show > div children. */
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' || !('type' in (c as object))) continue
406
- const showNode = c as any
407
- const showChildren = showNode.props?.children ?? showNode.children
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) {
@@ -111,6 +111,34 @@ describe('Transition — SSR / initially-hidden children render', () => {
111
111
  expect(html).toContain('translateY(20px)')
112
112
  })
113
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
+
114
142
  it('merges the hidden class with any user-set class on the child', async () => {
115
143
  const html = await renderToString(
116
144
  h(Transition, {
@@ -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
+ })
@@ -211,4 +211,117 @@ describe('@pyreon/kinetic browser smoke', () => {
211
211
  expect(el()!.classList.contains('enter-active')).toBe(true)
212
212
  unmount()
213
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
+ })
214
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
- <Show when={shouldRender}>
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
- return (
159
- <Show
160
- when={shouldMount}
161
- fallback={
162
- unmount
163
- ? null
164
- : cloneVNode(props.children, {
165
- ref: mergedRef,
166
- style: mergeStyles(
167
- (props.children.props as Record<string, unknown>)?.style as
168
- | Record<string, string | number | undefined>
169
- | undefined,
170
- { display: 'none' },
171
- ),
172
- })
173
- }
174
- >
175
- {cloneVNode(props.children, { ref: mergedRef })}
176
- </Show>
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
- return (
135
- <Show
136
- when={shouldMount}
137
- fallback={
138
- effectiveUnmount
139
- ? null
140
- : h(
141
- props.config.tag,
142
- // mergeProps keeps every reactive HTML-attr getter; ref + the
143
- // hidden-state `display:none` style come last and win. The
144
- // one-time `props.htmlProps.style` read seeds the hidden
145
- // style display:none must compose over the user's style.
146
- mergeProps(props.htmlProps, {
147
- ref: mergedRef,
148
- style: {
149
- ...((props.htmlProps.style as CSSProperties) ?? {}),
150
- display: 'none',
151
- },
152
- }),
153
- props.children,
154
- )
155
- }
156
- >
157
- {h(
158
- props.config.tag,
159
- // Descriptor-preserving merge reactive HTML attrs keep their
160
- // getters; ref wins last. `{ ...props.htmlProps }` would freeze them.
161
- mergeProps(props.htmlProps, { ref: mergedRef }),
162
- props.children,
163
- )}
164
- </Show>
165
- )
143
+ // Initially-visible kinetic-mode Transitions keep the original Show-gated
144
+ // mount, preserving the documented runtime-unmount semantic for the
145
+ // visible→hidden transition. The SSR bug (children dropped from prerendered
146
+ // HTML) only fires for the initially-HIDDEN case below, where
147
+ // `<Show when={false}>` renders `null` on the server — leaving SSG sites
148
+ // using kinetic-mode transitions (e.g. `kinetic('div').preset(fadeUp)` with
149
+ // `show: () => false` at SSR, the scroll-reveal pattern via
150
+ // `useIntersection`) without structural content for SEO / social scrapers
151
+ // / accessibility tools / no-JS users.
152
+ //
153
+ // Mirrors the same fix shape applied to the top-level `<Transition>` in
154
+ // PR #717. Ecosystem norm (Framer Motion / react-transition-group / react-
155
+ // spring): content is structural, animation is visual.
156
+ const wasInitiallyShown = props.show()
157
+ if (wasInitiallyShown) {
158
+ return (
159
+ <Show
160
+ when={shouldMount}
161
+ fallback={
162
+ effectiveUnmount
163
+ ? null
164
+ : h(
165
+ props.config.tag,
166
+ // mergeProps keeps every reactive HTML-attr getter; ref + the
167
+ // hidden-state `display:none` style come last and win. The
168
+ // one-time `props.htmlProps.style` read seeds the hidden
169
+ // style display:none must compose over the user's style.
170
+ mergeProps(props.htmlProps, {
171
+ ref: mergedRef,
172
+ style: {
173
+ ...((props.htmlProps.style as CSSProperties) ?? {}),
174
+ display: 'none',
175
+ },
176
+ }),
177
+ props.children,
178
+ )
179
+ }
180
+ >
181
+ {h(
182
+ props.config.tag,
183
+ // Descriptor-preserving merge — reactive HTML attrs keep their
184
+ // getters; ref wins last. `{ ...props.htmlProps }` would freeze them.
185
+ mergeProps(props.htmlProps, { ref: mergedRef }),
186
+ props.children,
187
+ )}
188
+ </Show>
189
+ )
190
+ }
191
+
192
+ // Initially-hidden path — ecosystem-correct: always emit children with
193
+ // hidden-state class/style inlined so SSG / SEO / social scrapers / no-JS
194
+ // users see structural content. `leaveTo` (explicit hidden-end state)
195
+ // wins; falls back to `enterFrom` (pre-enter state) for scroll-reveal
196
+ // patterns that only configure the enter side. The existing
197
+ // `watch(stage)` effect drives the enter animation when `show` flips
198
+ // true; the symmetric `applyEnter` above clears these residual classes.
199
+ //
200
+ // Trade-off: for initially-hidden kinetic-mode Transitions, `unmount: true`
201
+ // no longer triggers a true DOM removal after a later leave animation
202
+ // completes — element stays in DOM with the leave-to class applied.
203
+ // Initially-visible Transitions (the branch above) keep the unmount
204
+ // semantic. Matches Framer Motion / react-transition-group conventions
205
+ // and is the price of SSR correctness.
206
+ // Mirrors the class picker: prefer `leaveTo`/`leaveToStyle` (explicit
207
+ // leave-end / hidden state) and fall back to `enterFrom`/`enterStyle`
208
+ // (pre-enter state). The fallback covers the preset path —
209
+ // `@pyreon/kinetic-presets` factories (fadeUp, slideLeft, blurInUp, …)
210
+ // populate `enterStyle` as the hidden state and may not set
211
+ // `leaveToStyle` at all; without this fallback, presets would SSR-render
212
+ // VISIBLE → flash-on-hydration.
213
+ const hiddenClass = props.config.leaveTo ?? props.config.enterFrom
214
+ const hiddenStyle = props.config.leaveToStyle ?? props.config.enterStyle
215
+ const childClass = props.htmlProps.class
216
+ const mergedClass = hiddenClass
217
+ ? cx([childClass as Parameters<typeof cx>[0], hiddenClass])
218
+ : undefined
219
+ const mergedStyle = hiddenStyle
220
+ ? { ...((props.htmlProps.style as CSSProperties) ?? {}), ...hiddenStyle }
221
+ : undefined
222
+
223
+ const extra: Record<string, unknown> = { ref: mergedRef }
224
+ if (mergedClass !== undefined) extra.class = mergedClass
225
+ if (mergedStyle !== undefined) extra.style = mergedStyle
226
+
227
+ return h(props.config.tag, mergeProps(props.htmlProps, extra), props.children)
166
228
  }
167
229
 
168
230
  export default TransitionRenderer