@pyreon/kinetic 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/kinetic",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "CSS-transition-based animation components for Pyreon",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,11 +42,12 @@
42
42
  "typecheck": "tsc --noEmit"
43
43
  },
44
44
  "devDependencies": {
45
- "@pyreon/core": "^0.20.0",
46
- "@pyreon/reactivity": "^0.20.0",
47
- "@pyreon/runtime-dom": "^0.20.0",
48
- "@pyreon/test-utils": "^0.13.7",
49
- "@pyreon/typescript": "^0.20.0",
45
+ "@pyreon/core": "^0.21.0",
46
+ "@pyreon/reactivity": "^0.21.0",
47
+ "@pyreon/runtime-dom": "^0.21.0",
48
+ "@pyreon/runtime-server": "^0.21.0",
49
+ "@pyreon/test-utils": "^0.13.8",
50
+ "@pyreon/typescript": "^0.21.0",
50
51
  "@vitest/browser-playwright": "^4.1.4",
51
52
  "@vitus-labs/tools-rolldown": "^2.3.0"
52
53
  },
@@ -54,8 +55,8 @@
54
55
  "node": ">= 22"
55
56
  },
56
57
  "dependencies": {
57
- "@pyreon/core": "^0.20.0",
58
- "@pyreon/reactivity": "^0.20.0",
59
- "@pyreon/runtime-dom": "^0.20.0"
58
+ "@pyreon/core": "^0.21.0",
59
+ "@pyreon/reactivity": "^0.21.0",
60
+ "@pyreon/runtime-dom": "^0.21.0"
60
61
  }
61
62
  }
@@ -1,5 +1,5 @@
1
1
  import type { VNode } from '@pyreon/core'
2
- import { createRef, Show } from '@pyreon/core'
2
+ import { createRef, cx, Show } from '@pyreon/core'
3
3
  import { watch } from '@pyreon/reactivity'
4
4
  import type { ClassTransitionProps, StyleTransitionProps, TransitionProps } from './types'
5
5
  import useAnimationEnd from './useAnimationEnd'
@@ -16,8 +16,22 @@ const applyEnter = (
16
16
  enterStyle,
17
17
  enterToStyle,
18
18
  enterTransition,
19
+ leave,
20
+ leaveFrom,
21
+ leaveTo,
19
22
  }: ClassTransitionProps & StyleTransitionProps,
20
23
  ) => {
24
+ // Symmetric to applyLeave's `removeClasses(enter)` / `removeClasses(enterTo)`:
25
+ // clear any residual leave-cycle classes — including the `leaveTo` /
26
+ // `enterFrom` class the SSR / initial-hidden render path inlines for
27
+ // ecosystem-correct structural content (see the `wasInitiallyShown`
28
+ // branch below). Without this, the SSR-baked hidden-state class would
29
+ // compete with `enterTo`'s CSS rules and the enter animation would
30
+ // visually fight itself.
31
+ removeClasses(el, leave)
32
+ removeClasses(el, leaveFrom)
33
+ removeClasses(el, leaveTo)
34
+
21
35
  addClasses(el, enter)
22
36
  addClasses(el, enterFrom)
23
37
  if (enterStyle) Object.assign(el.style, enterStyle)
@@ -170,24 +184,73 @@ const Transition = (props: TransitionProps): VNode | null => {
170
184
  { immediate: true },
171
185
  )
172
186
 
173
- return (
174
- <Show
175
- when={shouldMount}
176
- fallback={
177
- unmount
178
- ? null
179
- : cloneVNode(props.children, {
180
- ref: mergedRef,
181
- style: mergeStyles(
182
- childProps.style as Record<string, string | number | undefined> | undefined,
183
- { display: 'none' },
184
- ),
185
- })
186
- }
187
- >
188
- {cloneVNode(props.children, { ref: mergedRef })}
189
- </Show>
187
+ // Initially-visible Transitions keep the original Show-gated mount,
188
+ // which preserves the documented runtime-unmount semantic for the
189
+ // visible → hidden transition (modal close, dropdown collapse, etc.).
190
+ // The SSR bug (children dropped from prerendered HTML) only fires for
191
+ // the initially-HIDDEN case below, because `<Show when={false}>`
192
+ // renders `null` on the server.
193
+ const wasInitiallyShown = props.show()
194
+ if (wasInitiallyShown) {
195
+ return (
196
+ <Show
197
+ when={shouldMount}
198
+ fallback={
199
+ unmount
200
+ ? null
201
+ : cloneVNode(props.children, {
202
+ ref: mergedRef,
203
+ style: mergeStyles(
204
+ childProps.style as Record<string, string | number | undefined> | undefined,
205
+ { display: 'none' },
206
+ ),
207
+ })
208
+ }
209
+ >
210
+ {cloneVNode(props.children, { ref: mergedRef })}
211
+ </Show>
212
+ )
213
+ }
214
+
215
+ // Initially-hidden path — ecosystem-correct (Framer Motion / react-
216
+ // transition-group / react-spring all render children in SSR regardless
217
+ // of animation state; visual hiding is class/style only). Always emits
218
+ // children so SSG / SEO / social scrapers / no-JS users see the
219
+ // structural content. The hidden visual is supplied by `leaveTo`
220
+ // (explicit hidden-end state) or `enterFrom` (pre-enter state — covers
221
+ // the scroll-reveal pattern that only configures the enter side).
222
+ //
223
+ // Trade-off: for an initially-hidden Transition, `unmount: true` no
224
+ // longer triggers a true DOM removal after a later leave animation
225
+ // completes — the element stays in DOM with the leave-to class
226
+ // applied. Initially-visible Transitions keep the unmount semantic
227
+ // (the branch above). This matches Framer Motion / react-transition-
228
+ // group conventions and is the price of SSR correctness; the rare
229
+ // user who needs true unmount on a started-hidden element can drive
230
+ // mount/unmount themselves outside `<Transition>`.
231
+ //
232
+ // The `watch(stage)` effect above drives the enter animation when
233
+ // `show` flips true; `applyEnter` (above) clears these residual
234
+ // hidden-state classes so they don't fight `enterTo`.
235
+ const hiddenClass = props.leaveTo ?? props.enterFrom
236
+ const hiddenStyle = props.leaveToStyle
237
+ const childClass = childProps.class
238
+ const mergedClass = hiddenClass
239
+ ? cx([childClass as Parameters<typeof cx>[0], hiddenClass])
240
+ : undefined
241
+ const mergedStyle = mergeStyles(
242
+ childProps.style as Record<string, string | number | undefined> | undefined,
243
+ hiddenStyle,
190
244
  )
245
+
246
+ // Build extra-props carefully — undefined values must NOT be passed to
247
+ // cloneVNode because `{...vnode.props, ...extraProps}` spreads them and
248
+ // overrides any user-set `class`/`style` on the child vnode with undefined.
249
+ const extra: Record<string, unknown> = { ref: mergedRef }
250
+ if (mergedClass !== undefined) extra.class = mergedClass
251
+ if (mergedStyle !== undefined) extra.style = mergedStyle
252
+
253
+ return cloneVNode(props.children, extra)
191
254
  }
192
255
 
193
256
  export default Transition
@@ -0,0 +1,155 @@
1
+ /**
2
+ * SSR regression coverage for `<Transition>` (the missing test layer that
3
+ * let the children-dropped-on-SSR bug ship).
4
+ *
5
+ * Background. `<Transition show={() => false} ...>` used to render `<Show
6
+ * when={false} fallback={null}>` on the server, which emitted EMPTY HTML —
7
+ * any SSG site using kinetic for scroll-triggered reveal (the documented
8
+ * `useIntersection` + sticky-signal pattern, where `show` is false at SSR
9
+ * because IntersectionObserver can't fire until client hydration) shipped
10
+ * with the wrapped content STRUCTURALLY ABSENT from the prerendered HTML.
11
+ * Bad for SEO, social scrapers, accessibility tools, and no-JS users.
12
+ *
13
+ * Ecosystem norm (the framing this fix aligns Pyreon with): Framer Motion,
14
+ * react-transition-group, react-spring, AutoAnimate all render children in
15
+ * SSR regardless of animation state and only apply animation styles on the
16
+ * client. "Content is structural, animation is visual."
17
+ *
18
+ * What the fix changes. `Transition` now branches at setup on
19
+ * `props.show()`:
20
+ * - initially-visible → existing `<Show>`-gated mount (unchanged;
21
+ * preserves the runtime-unmount semantic for the visible→hidden case)
22
+ * - initially-hidden → always render children with hidden-state classes
23
+ * inlined (`leaveTo` if defined, else `enterFrom` — covers the
24
+ * scroll-reveal pattern that only configures the enter side). The
25
+ * existing `watch(stage)` effect drives the enter animation when
26
+ * `show` flips true.
27
+ *
28
+ * Why these tests are load-bearing. Zero existing tests exercised
29
+ * `show: () => false` initial state (the bug class). The fact that this
30
+ * shipped is exactly the "no test catches it because no test runs the real
31
+ * path" failure mode the `test-environment-parity.md` rule was written to
32
+ * prevent — both real `h()` AND a SSR-driving environment (`renderToString`)
33
+ * are needed to catch it.
34
+ *
35
+ * Bisect-verified: reverting `Transition.tsx`'s `wasInitiallyShown` branch
36
+ * fails every spec below with `expected '' to contain '...'` (the empty-
37
+ * children bug). Restored → all green.
38
+ */
39
+
40
+ import { h } from '@pyreon/core'
41
+ import { renderToString } from '@pyreon/runtime-server'
42
+ import { describe, expect, it } from 'vitest'
43
+ import Transition from '../Transition'
44
+
45
+ describe('Transition — SSR / initially-hidden children render', () => {
46
+ it('emits children when show=false initially (was: <Show fallback={null}> → empty)', async () => {
47
+ // The canonical bug shape — scroll-reveal pattern at SSR time.
48
+ const html = await renderToString(
49
+ h(Transition, {
50
+ show: () => false,
51
+ enterFrom: 'opacity-0',
52
+ enterTo: 'opacity-100',
53
+ enter: 'transition-opacity duration-300',
54
+ children: h('section', null, 'real content for SEO + social scrapers'),
55
+ }),
56
+ )
57
+ // Structural content must land in the prerendered HTML. Pre-fix this
58
+ // assertion failed with `expected '' to contain '...'`.
59
+ expect(html).toContain('<section')
60
+ expect(html).toContain('real content for SEO + social scrapers')
61
+ })
62
+
63
+ it('inlines `leaveTo` as the hidden-state class (explicit hidden-end state takes precedence)', async () => {
64
+ const html = await renderToString(
65
+ h(Transition, {
66
+ show: () => false,
67
+ enterFrom: 'opacity-0 translate-y-4', // present but NOT selected
68
+ enterTo: 'opacity-100 translate-y-0',
69
+ leave: 'transition-opacity',
70
+ leaveFrom: 'opacity-100',
71
+ leaveTo: 'is-hidden opacity-0', // ← explicit hidden-end state, selected
72
+ children: h('div', null, 'panel content'),
73
+ }),
74
+ )
75
+ expect(html).toContain('is-hidden opacity-0')
76
+ expect(html).toContain('panel content')
77
+ // The competing `enterFrom` should NOT be applied (leaveTo wins).
78
+ expect(html).not.toContain('translate-y-4')
79
+ })
80
+
81
+ it('falls back to `enterFrom` as the hidden class for scroll-reveal patterns (only enter side configured)', async () => {
82
+ // The exact pattern the reported bug surfaced on — only the enter
83
+ // animation is configured (because IO triggers `show` true; there's
84
+ // no leave side for the reveal pattern).
85
+ const html = await renderToString(
86
+ h(Transition, {
87
+ show: () => false,
88
+ enter: 'transition-all duration-700',
89
+ enterFrom: 'opacity-0 translate-y-8',
90
+ enterTo: 'opacity-100 translate-y-0',
91
+ children: h('section', { id: 'resume-section' }, 'work history goes here'),
92
+ }),
93
+ )
94
+ expect(html).toContain('id="resume-section"')
95
+ expect(html).toContain('work history goes here')
96
+ // enterFrom IS the resting hidden state for this pattern.
97
+ expect(html).toContain('opacity-0 translate-y-8')
98
+ })
99
+
100
+ it('inlines `leaveToStyle` as the hidden inline style when defined', async () => {
101
+ const html = await renderToString(
102
+ h(Transition, {
103
+ show: () => false,
104
+ leaveTo: 'animated-section',
105
+ leaveToStyle: { opacity: 0, transform: 'translateY(20px)' },
106
+ children: h('article', null, 'article body'),
107
+ }),
108
+ )
109
+ expect(html).toContain('article body')
110
+ expect(html).toContain('opacity: 0')
111
+ expect(html).toContain('translateY(20px)')
112
+ })
113
+
114
+ it('merges the hidden class with any user-set class on the child', async () => {
115
+ const html = await renderToString(
116
+ h(Transition, {
117
+ show: () => false,
118
+ leaveTo: 'is-hidden',
119
+ children: h('div', { class: 'card card--featured' }, 'merged-class content'),
120
+ }),
121
+ )
122
+ expect(html).toContain('merged-class content')
123
+ expect(html).toContain('card')
124
+ expect(html).toContain('card--featured')
125
+ expect(html).toContain('is-hidden')
126
+ })
127
+
128
+ it('emits children unchanged when neither leaveTo nor enterFrom is defined (graceful no-op)', async () => {
129
+ // An unusual config — no enter/leave classes at all. Children should
130
+ // still render structurally (the SEO/SSG contract); no hidden class
131
+ // is appended because there's nothing to append.
132
+ const html = await renderToString(
133
+ h(Transition, {
134
+ show: () => false,
135
+ children: h('div', null, 'bare content'),
136
+ }),
137
+ )
138
+ expect(html).toContain('bare content')
139
+ })
140
+
141
+ it('initially-visible Transition (show=true) renders children normally — unchanged behavior', async () => {
142
+ // The other branch of the fix — initially-visible Transitions keep
143
+ // the original `<Show>`-gated path. This spec locks in the no-
144
+ // regression contract for the existing common case.
145
+ const html = await renderToString(
146
+ h(Transition, {
147
+ show: () => true,
148
+ leaveTo: 'is-hidden', // must NOT leak onto initially-visible
149
+ children: h('main', null, 'visible from the start'),
150
+ }),
151
+ )
152
+ expect(html).toContain('visible from the start')
153
+ expect(html).not.toContain('is-hidden')
154
+ })
155
+ })
@@ -144,4 +144,71 @@ 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
+ })
147
214
  })