@pyreon/kinetic 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -119
- package/lib/index.js +73 -13
- package/package.json +11 -11
- package/src/Stagger.tsx +7 -2
- package/src/Transition.tsx +30 -6
- package/src/__tests__/Collapse.test.tsx +25 -4
- package/src/__tests__/Transition.ssr.test.tsx +28 -0
- package/src/__tests__/kinetic-modes.ssr.test.tsx +214 -0
- package/src/__tests__/kinetic.browser.test.tsx +113 -0
- package/src/__tests__/stagger-component-children-hydration.test.tsx +191 -0
- package/src/__tests__/top-level-transition-stagger-function-children.test.tsx +141 -0
- package/src/kinetic/CollapseRenderer.tsx +34 -3
- package/src/kinetic/StaggerRenderer.tsx +4 -2
- package/src/kinetic/TransitionItem.tsx +92 -22
- package/src/kinetic/TransitionRenderer.tsx +95 -33
- package/src/utils.ts +28 -0
|
@@ -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) {
|
|
@@ -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
|
})
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/** @jsxImportSource @pyreon/core */
|
|
2
|
+
/**
|
|
3
|
+
* Regression: `kinetic('div').stagger()` with `show={() => true}` +
|
|
4
|
+
* `appear` + multiple component-VNode children rendered `<undefined>`
|
|
5
|
+
* tags in place of the children's actual DOM post-hydrate.
|
|
6
|
+
*
|
|
7
|
+
* Real-app reporter (examples/bokisch.com Intro section): SSG'd HTML
|
|
8
|
+
* carried `<h1>Hello</h1>` + tagline + icons; client hydration produced
|
|
9
|
+
* `<undefined></undefined>` tags (literal HTML element with tagName
|
|
10
|
+
* "UNDEFINED") + `<!--pyreon-->` markers in place of every child.
|
|
11
|
+
*
|
|
12
|
+
* Bug class — Pyreon-compiler prop-inlining + cloneVNode-on-a-function:
|
|
13
|
+
*
|
|
14
|
+
* 1. The compiler rewrites local `const children = obj.x` then
|
|
15
|
+
* `<Comp>{children}</Comp>` as `Comp({..., children: () => obj.x})`.
|
|
16
|
+
* Component receives `props.children` as a FUNCTION, not an array.
|
|
17
|
+
*
|
|
18
|
+
* 2. StaggerRenderer iterated `(Array.isArray(children) ? children : [children])`
|
|
19
|
+
* directly. `[function].filter(isVNode)` collapsed to `[]` → the
|
|
20
|
+
* kinetic `<div>` rendered with zero children.
|
|
21
|
+
*
|
|
22
|
+
* 3. Even after StaggerRenderer's fix, TransitionItem's `cloneVNode(props.children, {ref})`
|
|
23
|
+
* tried to clone the same function-wrapped value (also auto-wrapped
|
|
24
|
+
* by the compiler one level down — `{cloneVNode(child, {style})}`
|
|
25
|
+
* became `() => cloneVNode(child, {style})`). Spreading a function
|
|
26
|
+
* via `{...fn, props: {...}}` yields `{props: {...}}` (no own
|
|
27
|
+
* enumerable properties on functions) — the resulting vnode had
|
|
28
|
+
* `type: undefined`. mountElement called `document.createElement(undefined)`
|
|
29
|
+
* → the browser produced literal `<undefined>` tags.
|
|
30
|
+
*
|
|
31
|
+
* Fix: `resolveChildren` helper in both StaggerRenderer (iteration) AND
|
|
32
|
+
* TransitionItem (cloning). Unwraps function-wrapped children eagerly
|
|
33
|
+
* since kinetic snapshots children at render time and does not observe
|
|
34
|
+
* children changes.
|
|
35
|
+
*
|
|
36
|
+
* Bisect-verified: reverting either `resolveChildren` call in this PR
|
|
37
|
+
* fails this spec — StaggerRenderer revert produces zero children in the
|
|
38
|
+
* kinetic `<div>`; TransitionItem revert produces `<undefined>` tags
|
|
39
|
+
* with the right cloned style.
|
|
40
|
+
*/
|
|
41
|
+
import type { VNode } from '@pyreon/core'
|
|
42
|
+
import { h } from '@pyreon/core'
|
|
43
|
+
import { mount } from '@pyreon/runtime-dom'
|
|
44
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
45
|
+
import kinetic from '../kinetic'
|
|
46
|
+
import TransitionItem from '../kinetic/TransitionItem'
|
|
47
|
+
|
|
48
|
+
// Build a kinetic Component VNode whose `props.children` is a FUNCTION
|
|
49
|
+
// (not an array), mirroring what the Pyreon vite-plugin emits when JSX
|
|
50
|
+
// children are inlined back at the call site (`<Entrance>{children}</Entrance>`
|
|
51
|
+
// → `jsx(Entrance, { children: () => h.children })`). The kinetic test
|
|
52
|
+
// pipeline uses `vl_rolldown_build` which does NOT do Pyreon's
|
|
53
|
+
// prop-inlining, so we construct the shape directly.
|
|
54
|
+
const buildEntranceWithFunctionChildren = (
|
|
55
|
+
// Wider call signature — kinetic('div').stagger() returns a stagger-mode
|
|
56
|
+
// component whose precise typed shape (`KineticComponent<'div', 'stagger'>`)
|
|
57
|
+
// is narrower than what `kinetic()`'s default returns. The function-
|
|
58
|
+
// children wrapper bypasses the strict children-required typing.
|
|
59
|
+
Entrance: (props: Record<string, unknown>) => VNode | null,
|
|
60
|
+
childArray: VNode[],
|
|
61
|
+
): VNode => {
|
|
62
|
+
// h() puts children in vnode.children (rest args). For mountComponent's
|
|
63
|
+
// merge to leave props.children alone, set it explicitly here.
|
|
64
|
+
return h(Entrance, {
|
|
65
|
+
show: () => true,
|
|
66
|
+
appear: true,
|
|
67
|
+
children: (() => childArray) as unknown as VNode[],
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let containers: HTMLElement[] = []
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
for (const c of containers) c.remove()
|
|
74
|
+
containers = []
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('kinetic("div").stagger() — function-wrapped children survive render', () => {
|
|
78
|
+
it('iterates function-wrapped children correctly (no <undefined> tags)', () => {
|
|
79
|
+
const Entrance = kinetic('div')
|
|
80
|
+
.enter({ opacity: '0' })
|
|
81
|
+
.enterTo({ opacity: '1' })
|
|
82
|
+
.stagger({ interval: 20 })
|
|
83
|
+
|
|
84
|
+
const tree = buildEntranceWithFunctionChildren(Entrance as never, [
|
|
85
|
+
h('h1', { 'data-id': 'heading' }, 'Hello'),
|
|
86
|
+
h('p', { 'data-id': 'tagline' }, 'tagline'),
|
|
87
|
+
h('ul', { 'data-id': 'icons' }, h('li', null, 'icon-a')),
|
|
88
|
+
])
|
|
89
|
+
|
|
90
|
+
const container = document.createElement('div')
|
|
91
|
+
document.body.appendChild(container)
|
|
92
|
+
containers.push(container)
|
|
93
|
+
|
|
94
|
+
const dispose = mount(tree as VNode, container)
|
|
95
|
+
|
|
96
|
+
// Children are rendered with proper element tags — NOT <undefined>
|
|
97
|
+
const heading = container.querySelector('[data-id="heading"]')
|
|
98
|
+
const tagline = container.querySelector('[data-id="tagline"]')
|
|
99
|
+
const icons = container.querySelector('[data-id="icons"]')
|
|
100
|
+
|
|
101
|
+
expect(
|
|
102
|
+
heading,
|
|
103
|
+
`heading missing — pre-fix the function-wrapped child got mounted as <undefined>. ` +
|
|
104
|
+
`container.innerHTML=${container.innerHTML.slice(0, 600)}`,
|
|
105
|
+
).not.toBeNull()
|
|
106
|
+
expect(heading?.tagName).toBe('H1')
|
|
107
|
+
expect(heading?.textContent).toBe('Hello')
|
|
108
|
+
|
|
109
|
+
expect(tagline).not.toBeNull()
|
|
110
|
+
expect(tagline?.tagName).toBe('P')
|
|
111
|
+
|
|
112
|
+
expect(icons).not.toBeNull()
|
|
113
|
+
expect(icons?.tagName).toBe('UL')
|
|
114
|
+
|
|
115
|
+
// Sanity: no `<undefined>` tags should exist anywhere (pre-fix
|
|
116
|
+
// TransitionItem's cloneVNode(props.children, {ref}) on a function
|
|
117
|
+
// produced `{type: undefined, props: {ref}}` → mountElement called
|
|
118
|
+
// document.createElement(undefined) → `<undefined>` element).
|
|
119
|
+
expect(container.querySelector('undefined')).toBeNull()
|
|
120
|
+
|
|
121
|
+
dispose()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('TransitionItem resolves function-wrapped children before cloneVNode (no <undefined> tag)', () => {
|
|
125
|
+
// Direct test for the SECOND fix-site — TransitionItem's
|
|
126
|
+
// `cloneVNode(props.children, {ref})`. Pre-fix, when the parent
|
|
127
|
+
// (StaggerRenderer/GroupRenderer) emits `<TransitionItem>{cloneVNode(c, {style})}</TransitionItem>`
|
|
128
|
+
// under the Pyreon vite-plugin, the compiler wraps the JSX child as
|
|
129
|
+
// `() => cloneVNode(c, {style})`. TransitionItem then receives
|
|
130
|
+
// `props.children = function`. `cloneVNode(function, {ref})` spreads
|
|
131
|
+
// the function (no own enumerable properties) → produces
|
|
132
|
+
// `{type: undefined, props: {ref}}` → mountElement creates literal
|
|
133
|
+
// `<undefined>` tag.
|
|
134
|
+
const childVNode = h('h1', { 'data-id': 'ti-heading' }, 'Hello')
|
|
135
|
+
const tree = h(TransitionItem, {
|
|
136
|
+
show: () => true,
|
|
137
|
+
appear: false,
|
|
138
|
+
timeout: 100,
|
|
139
|
+
enterStyle: { opacity: '0' },
|
|
140
|
+
enterToStyle: { opacity: '1' },
|
|
141
|
+
enterTransition: 'opacity 50ms ease',
|
|
142
|
+
// Function-wrapped children, mirroring the compiler's emit.
|
|
143
|
+
children: (() => childVNode) as unknown as VNode,
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const container = document.createElement('div')
|
|
147
|
+
document.body.appendChild(container)
|
|
148
|
+
containers.push(container)
|
|
149
|
+
|
|
150
|
+
const dispose = mount(tree as VNode, container)
|
|
151
|
+
|
|
152
|
+
const heading = container.querySelector('[data-id="ti-heading"]')
|
|
153
|
+
expect(
|
|
154
|
+
heading,
|
|
155
|
+
`heading missing — pre-fix TransitionItem cloned the function, ` +
|
|
156
|
+
`producing <undefined>. container.innerHTML=${container.innerHTML.slice(0, 400)}`,
|
|
157
|
+
).not.toBeNull()
|
|
158
|
+
expect(heading?.tagName).toBe('H1')
|
|
159
|
+
expect(heading?.textContent).toBe('Hello')
|
|
160
|
+
expect(container.querySelector('undefined')).toBeNull()
|
|
161
|
+
|
|
162
|
+
dispose()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('iterates static-array children correctly (control — was always working)', () => {
|
|
166
|
+
const Entrance = kinetic('div')
|
|
167
|
+
.enter({ opacity: '0' })
|
|
168
|
+
.enterTo({ opacity: '1' })
|
|
169
|
+
.stagger({ interval: 20 })
|
|
170
|
+
|
|
171
|
+
// No compiler wrap — children as a plain array.
|
|
172
|
+
const tree = h(
|
|
173
|
+
Entrance,
|
|
174
|
+
{ show: () => true, appear: true },
|
|
175
|
+
h('h1', { 'data-id': 'heading-static' }, 'Static'),
|
|
176
|
+
h('p', { 'data-id': 'tagline-static' }, 't'),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
const container = document.createElement('div')
|
|
180
|
+
document.body.appendChild(container)
|
|
181
|
+
containers.push(container)
|
|
182
|
+
|
|
183
|
+
const dispose = mount(tree as VNode, container)
|
|
184
|
+
|
|
185
|
+
expect(container.querySelector('[data-id="heading-static"]')?.tagName).toBe('H1')
|
|
186
|
+
expect(container.querySelector('[data-id="tagline-static"]')?.tagName).toBe('P')
|
|
187
|
+
expect(container.querySelector('undefined')).toBeNull()
|
|
188
|
+
|
|
189
|
+
dispose()
|
|
190
|
+
})
|
|
191
|
+
})
|