@pyreon/kinetic 0.19.0 → 0.20.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, onMount, onUnmount } from "@pyreon/core";
1
+ import { Show, createRef, h, mergeProps, onMount, onUnmount, splitProps } from "@pyreon/core";
2
2
  import { runUntracked, signal, watch } from "@pyreon/reactivity";
3
3
  import { jsx } from "@pyreon/core/jsx-runtime";
4
4
 
@@ -172,11 +172,10 @@ const CollapseRenderer = ({ config, htmlProps, show, appear, timeout, transition
172
172
  ...stage() !== "entered" ? { overflow: "hidden" } : {},
173
173
  ...stage() === "hidden" ? { height: "0px" } : stage() === "entered" ? { height: "auto" } : {}
174
174
  };
175
- return h(config.tag, {
175
+ return h(config.tag, mergeProps(htmlProps, {
176
176
  ref: wrapperRef,
177
- ...htmlProps,
178
177
  style: wrapperStyle
179
- }, /* @__PURE__ */ jsx(Show, {
178
+ }), /* @__PURE__ */ jsx(Show, {
180
179
  when: shouldRender,
181
180
  children: /* @__PURE__ */ jsx("div", {
182
181
  ref: contentRef,
@@ -479,7 +478,7 @@ const GroupRenderer = ({ config, htmlProps, appear, timeout, callbacks, children
479
478
  children: element
480
479
  });
481
480
  });
482
- return h(config.tag, { ...htmlProps }, ...groupedChildren);
481
+ return h(config.tag, htmlProps, ...groupedChildren);
483
482
  });
484
483
  };
485
484
 
@@ -526,7 +525,7 @@ const StaggerRenderer = ({ config, htmlProps, show, appear, timeout, interval, r
526
525
  } })
527
526
  }, child.key ?? index);
528
527
  });
529
- return h(config.tag, { ...htmlProps }, ...staggeredChildren);
528
+ return h(config.tag, htmlProps, ...staggeredChildren);
530
529
  };
531
530
 
532
531
  //#endregion
@@ -613,18 +612,14 @@ const TransitionRenderer = (props) => {
613
612
  }, { immediate: true });
614
613
  return /* @__PURE__ */ jsx(Show, {
615
614
  when: shouldMount,
616
- fallback: effectiveUnmount ? null : h(props.config.tag, {
615
+ fallback: effectiveUnmount ? null : h(props.config.tag, mergeProps(props.htmlProps, {
617
616
  ref: mergedRef,
618
- ...props.htmlProps,
619
617
  style: {
620
618
  ...props.htmlProps.style ?? {},
621
619
  display: "none"
622
620
  }
623
- }, props.children),
624
- children: h(props.config.tag, {
625
- ref: mergedRef,
626
- ...props.htmlProps
627
- }, props.children)
621
+ }), props.children),
622
+ children: h(props.config.tag, mergeProps(props.htmlProps, { ref: mergedRef }), props.children)
628
623
  });
629
624
  };
630
625
 
@@ -651,10 +646,7 @@ const KINETIC_KEYS = new Set([
651
646
  */
652
647
  const createKineticComponent = (config) => {
653
648
  const Component = (props) => {
654
- const htmlProps = {};
655
- const kineticProps = {};
656
- for (const key in props) if (KINETIC_KEYS.has(key)) kineticProps[key] = props[key];
657
- else htmlProps[key] = props[key];
649
+ const [kineticProps, htmlPropsWithChildren] = splitProps(props, [...KINETIC_KEYS]);
658
650
  const { show, appear, unmount, timeout, transition, interval, reverseLeave, onEnter, onAfterEnter, onLeave, onAfterLeave } = kineticProps;
659
651
  const callbacks = {
660
652
  onEnter: onEnter ?? config.onEnter,
@@ -662,7 +654,8 @@ const createKineticComponent = (config) => {
662
654
  onLeave: onLeave ?? config.onLeave,
663
655
  onAfterLeave: onAfterLeave ?? config.onAfterLeave
664
656
  };
665
- const { children, ...restHtml } = htmlProps;
657
+ const [childHolder, restHtml] = splitProps(htmlPropsWithChildren, ["children"]);
658
+ const children = childHolder.children;
666
659
  if (config.mode === "collapse") return /* @__PURE__ */ jsx(CollapseRenderer, {
667
660
  config,
668
661
  htmlProps: restHtml,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/kinetic",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "CSS-transition-based animation components for Pyreon",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,11 +42,11 @@
42
42
  "typecheck": "tsc --noEmit"
43
43
  },
44
44
  "devDependencies": {
45
- "@pyreon/core": "^0.19.0",
46
- "@pyreon/reactivity": "^0.19.0",
47
- "@pyreon/runtime-dom": "^0.19.0",
48
- "@pyreon/test-utils": "^0.13.6",
49
- "@pyreon/typescript": "^0.19.0",
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",
50
50
  "@vitest/browser-playwright": "^4.1.4",
51
51
  "@vitus-labs/tools-rolldown": "^2.3.0"
52
52
  },
@@ -54,8 +54,8 @@
54
54
  "node": ">= 22"
55
55
  },
56
56
  "dependencies": {
57
- "@pyreon/core": "^0.19.0",
58
- "@pyreon/reactivity": "^0.19.0",
59
- "@pyreon/runtime-dom": "^0.19.0"
57
+ "@pyreon/core": "^0.20.0",
58
+ "@pyreon/reactivity": "^0.20.0",
59
+ "@pyreon/runtime-dom": "^0.20.0"
60
60
  }
61
61
  }
@@ -1,11 +1,42 @@
1
1
  /** @jsxImportSource @pyreon/core */
2
2
  import { describe, expect, it } from 'vitest'
3
+ import { _rp, h } from '@pyreon/core'
3
4
  import { signal } from '@pyreon/reactivity'
4
5
  import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
6
+ import kinetic from '../kinetic'
5
7
  import { nextFrame, mergeClassNames } from '../utils'
6
8
  import Transition from '../Transition'
7
9
 
8
10
  describe('@pyreon/kinetic browser smoke', () => {
11
+ // Regression: createKineticComponent + the 4 renderers used to value-copy
12
+ // user props (`for…in` / `const { children, ...rest }` / `{ ...htmlProps }`),
13
+ // firing every getter at component-setup time. The compiler emits a
14
+ // reactive HTML attr as `_rp(() => sig())`; mount.ts's makeReactiveProps
15
+ // turns it into a getter on `props`. The value-copy collapsed that getter
16
+ // to a static snapshot, freezing the attribute forever. The fix routes
17
+ // every hop through descriptor-preserving splitProps / mergeProps / by-ref
18
+ // so runtime-dom's applyProps detects the getter descriptor and wraps the
19
+ // read in a renderEffect. Bisect-verified: reverting createKineticComponent's
20
+ // splitProps split back to `for…in` fails this with `expected 'a' to be 'b'`.
21
+ it('forwards a reactive HTML attr through the kinetic pipeline (descriptor-preserving)', async () => {
22
+ const FadeDiv = kinetic('div')
23
+ const show = signal(true)
24
+ const v = signal('a')
25
+ const { container, unmount } = mountInBrowser(
26
+ h(
27
+ FadeDiv,
28
+ { show, 'data-testid': 'fd', 'data-variant': _rp(() => v()) },
29
+ h('span', { 'data-id': 'kc' }, 'hi'),
30
+ ),
31
+ )
32
+ const el = () => container.querySelector('[data-testid="fd"]')
33
+ expect(el()?.getAttribute('data-variant')).toBe('a')
34
+ v.set('b')
35
+ await flush()
36
+ expect(el()?.getAttribute('data-variant')).toBe('b')
37
+ unmount()
38
+ })
39
+
9
40
  it('Transition mounts a visible child into real DOM', async () => {
10
41
  const show = signal(true)
11
42
  const { container, unmount } = mountInBrowser(
@@ -51,6 +82,61 @@ describe('@pyreon/kinetic browser smoke', () => {
51
82
  expect(mergeClassNames(undefined, undefined)).toBe(undefined)
52
83
  })
53
84
 
85
+ // Regression: a kinetic-wrapped component must FORWARD a compiler-shaped
86
+ // reactive HTML attr (`<KineticDiv class={sig()}>` → `_rp(() => sig())`,
87
+ // which `makeReactiveProps` turns into a getter on `props`) so the DOM
88
+ // patches when the signal changes. The factory's prop split + the
89
+ // renderers' element spread used to value-copy props, firing the getter
90
+ // once at setup and freezing the attribute. We build the vnode with
91
+ // `h()` + `_rp()` directly because this browser config has no Pyreon
92
+ // compiler plugin — that faithfully reproduces the exact post-
93
+ // makeReactiveProps shape the mount pipeline sees in a real app.
94
+ //
95
+ // Bisect-verified: revert createKineticComponent's splitProps back to
96
+ // `htmlProps[key] = props[key]` → this fails with the className stuck
97
+ // at 'one' (`expected 'one' to be 'two'`). Restored → passes.
98
+ it('forwards a compiler-shaped reactive HTML attr — DOM patches on signal change (transition mode)', async () => {
99
+ const KineticDiv = kinetic('div')
100
+ const cls = signal('one')
101
+ const { container, unmount } = mountInBrowser(
102
+ h(
103
+ KineticDiv,
104
+ { show: () => true, class: _rp(() => cls()) },
105
+ h('span', { 'data-id': 'k' }, 'x'),
106
+ ),
107
+ )
108
+ const el = () => container.querySelector('div')
109
+ expect(el()?.querySelector('[data-id="k"]')?.textContent).toBe('x')
110
+ expect(el()?.className).toBe('one')
111
+
112
+ cls.set('two')
113
+ await flush()
114
+ expect(el()?.className).toBe('two')
115
+
116
+ cls.set('three')
117
+ await flush()
118
+ expect(el()?.className).toBe('three')
119
+ unmount()
120
+ })
121
+
122
+ it('forwards a compiler-shaped reactive HTML attr — collapse mode (mergeProps path)', async () => {
123
+ const KineticDiv = kinetic('div').collapse()
124
+ const cls = signal('a')
125
+ const { container, unmount } = mountInBrowser(
126
+ h(
127
+ KineticDiv,
128
+ { show: () => true, class: _rp(() => cls()) },
129
+ h('span', { 'data-id': 'c' }, 'y'),
130
+ ),
131
+ )
132
+ const el = () => container.querySelector('div')
133
+ expect(el()?.className).toBe('a')
134
+ cls.set('b')
135
+ await flush()
136
+ expect(el()?.className).toBe('b')
137
+ unmount()
138
+ })
139
+
54
140
  it('runs in a real browser — Vitest defines `process.env.NODE_ENV !== "production"`', () => {
55
141
  // Sanity check the test env: dev gates use bundler-agnostic
56
142
  // `process.env.NODE_ENV !== 'production'`. Vitest's Vite pipeline
@@ -1,5 +1,5 @@
1
1
  import type { VNode } from '@pyreon/core'
2
- import { createRef, h, Show } from '@pyreon/core'
2
+ import { createRef, h, mergeProps, Show } from '@pyreon/core'
3
3
  import { runUntracked, signal, watch } from '@pyreon/reactivity'
4
4
  import type { CSSProperties, TransitionCallbacks, TransitionStage } from '../types'
5
5
  import useAnimationEnd from '../useAnimationEnd'
@@ -166,9 +166,16 @@ const CollapseRenderer = ({
166
166
  ...(stage() === 'hidden' ? { height: '0px' } : stage() === 'entered' ? { height: 'auto' } : {}),
167
167
  }
168
168
 
169
+ // mergeProps (descriptor-preserving) instead of `{ ...htmlProps }` —
170
+ // every non-style HTML attr keeps its reactive getter; ref + the
171
+ // collapse-controlled style come last so they win (mergeProps is
172
+ // last-source-wins). The one-time `htmlProps.style` read above that
173
+ // seeds wrapperStyle is intentional: collapse OWNS the style prop
174
+ // (height/overflow are animation-driven), so a static merge of the
175
+ // user's initial style with the collapse overrides is correct here.
169
176
  return h(
170
177
  config.tag,
171
- { ref: wrapperRef, ...htmlProps, style: wrapperStyle },
178
+ mergeProps(htmlProps, { ref: wrapperRef, style: wrapperStyle }),
172
179
  <Show when={shouldRender}>
173
180
  <div ref={contentRef}>{children}</div>
174
181
  </Show>,
@@ -140,7 +140,9 @@ const GroupRenderer = ({
140
140
  )
141
141
  })
142
142
 
143
- return h(config.tag, { ...htmlProps }, ...groupedChildren)
143
+ // By reference — `{ ...htmlProps }` would value-copy and freeze any
144
+ // reactive HTML attr the kinetic split preserved as a getter.
145
+ return h(config.tag, htmlProps, ...groupedChildren)
144
146
  }) as unknown as VNode
145
147
  }
146
148
 
@@ -82,7 +82,11 @@ const StaggerRenderer = ({
82
82
  )
83
83
  })
84
84
 
85
- return h(config.tag, { ...htmlProps }, ...staggeredChildren)
85
+ // Pass htmlProps by reference — `{ ...htmlProps }` value-copies, firing
86
+ // any reactive getter the kinetic split preserved (frozen attr forever).
87
+ // runtime-dom's applyProps detects the getter descriptor on the live
88
+ // object and wraps it in renderEffect.
89
+ return h(config.tag, htmlProps, ...staggeredChildren)
86
90
  }
87
91
 
88
92
  export default StaggerRenderer
@@ -1,5 +1,5 @@
1
1
  import type { VNode } from '@pyreon/core'
2
- import { createRef, h, Show } from '@pyreon/core'
2
+ import { createRef, h, mergeProps, Show } from '@pyreon/core'
3
3
  import { watch } from '@pyreon/reactivity'
4
4
  import type { CSSProperties, TransitionCallbacks } from '../types'
5
5
  import useAnimationEnd from '../useAnimationEnd'
@@ -139,19 +139,28 @@ const TransitionRenderer = (props: TransitionRendererProps): VNode | null => {
139
139
  ? null
140
140
  : h(
141
141
  props.config.tag,
142
- {
142
+ // mergeProps keeps every reactive HTML-attr getter; ref + the
143
+ // hidden-state `display:none` style come last and win. The
144
+ // one-time `props.htmlProps.style` read seeds the hidden
145
+ // style — display:none must compose over the user's style.
146
+ mergeProps(props.htmlProps, {
143
147
  ref: mergedRef,
144
- ...props.htmlProps,
145
148
  style: {
146
149
  ...((props.htmlProps.style as CSSProperties) ?? {}),
147
150
  display: 'none',
148
151
  },
149
- },
152
+ }),
150
153
  props.children,
151
154
  )
152
155
  }
153
156
  >
154
- {h(props.config.tag, { ref: mergedRef, ...props.htmlProps }, props.children)}
157
+ {h(
158
+ props.config.tag,
159
+ // Descriptor-preserving merge — reactive HTML attrs keep their
160
+ // getters; ref wins last. `{ ...props.htmlProps }` would freeze them.
161
+ mergeProps(props.htmlProps, { ref: mergedRef }),
162
+ props.children,
163
+ )}
155
164
  </Show>
156
165
  )
157
166
  }
@@ -1,4 +1,5 @@
1
1
  import type { VNode } from '@pyreon/core'
2
+ import { splitProps } from '@pyreon/core'
2
3
  import type { CSSProperties, TransitionCallbacks } from '../types'
3
4
  import CollapseRenderer from './CollapseRenderer'
4
5
  import GroupRenderer from './GroupRenderer'
@@ -30,17 +31,25 @@ const createKineticComponent = <Tag extends string, Mode extends KineticMode = '
30
31
  config: KineticConfig,
31
32
  ): KineticComponent<Tag, Mode> => {
32
33
  const Component = (props: Record<string, unknown>): VNode | null => {
33
- // Separate kinetic-specific props from HTML pass-through props
34
- const htmlProps: Record<string, unknown> = {}
35
- const kineticProps: Record<string, unknown> = {}
36
-
37
- for (const key in props) {
38
- if (KINETIC_KEYS.has(key)) {
39
- kineticProps[key] = props[key]
40
- } else {
41
- htmlProps[key] = props[key]
42
- }
43
- }
34
+ // Separate kinetic-specific props from HTML pass-through props.
35
+ // MUST use splitProps (descriptor-preserving) — a plain
36
+ // `htmlProps[key] = props[key]` value-copy fires every getter at
37
+ // component-setup time. The compiler emits `<KineticDiv class={sig()}>`
38
+ // as `_rp(() => sig())`, which `makeReactiveProps` turns into a getter
39
+ // on `props`; reading it here (outside any tracking scope) would
40
+ // collapse it to a static snapshot and freeze the HTML attr forever.
41
+ // splitProps copies DESCRIPTORS via Object.getOwnPropertyDescriptor +
42
+ // Object.defineProperty, so the getter survives to the renderer's
43
+ // `h(config.tag, htmlProps)` where runtime-dom's applyProps detects
44
+ // the descriptor and wraps the read in renderEffect.
45
+ // `props` is `Record<string, unknown>`, so `Omit<…, string>` collapses
46
+ // to `{}` at the type level — the runtime split is correct (splitProps
47
+ // copies descriptors for every own key not in the pick set), only the
48
+ // inferred result types degrade. Cast back to the real shape.
49
+ const [kineticProps, htmlPropsWithChildren] = splitProps(props, [...KINETIC_KEYS]) as [
50
+ Record<string, unknown>,
51
+ Record<string, unknown>,
52
+ ]
44
53
 
45
54
  const {
46
55
  show,
@@ -71,8 +80,12 @@ const createKineticComponent = <Tag extends string, Mode extends KineticMode = '
71
80
  onAfterLeave: onAfterLeave ?? config.onAfterLeave,
72
81
  }
73
82
 
74
- // Extract children from htmlProps (it's not an HTML attribute)
75
- const { children, ...restHtml } = htmlProps
83
+ // Carve `children` out of the HTML pass-through set — also via
84
+ // splitProps so the remaining HTML attrs keep their getter
85
+ // descriptors (`const { children, ...restHtml } = …` is the same
86
+ // value-copy footgun as the split above).
87
+ const [childHolder, restHtml] = splitProps(htmlPropsWithChildren, ['children'])
88
+ const children = childHolder.children
76
89
 
77
90
  if (config.mode === 'collapse') {
78
91
  return (