@pyreon/elements 0.15.0 → 0.18.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.
@@ -1,68 +1,142 @@
1
- import type { VNode } from '@pyreon/core'
2
- import { Portal as CorePortal, h } from '@pyreon/core'
1
+ import { h } from '@pyreon/core'
2
+ import { mount } from '@pyreon/runtime-dom'
3
3
  import { describe, expect, it } from 'vitest'
4
4
  import { Portal } from '../Portal'
5
5
 
6
- const asVNode = (v: unknown) => v as VNode
7
-
8
6
  describe('Portal', () => {
9
- describe('rendering', () => {
10
- it('returns a VNode whose type is CorePortal', () => {
11
- const child = h('div', null, 'modal content')
12
- const result = asVNode(Portal({ children: child }))
13
- expect(result.type).toBe(CorePortal)
14
- })
7
+ describe('wrapper element creation', () => {
8
+ it('creates a per-instance wrapper appended to document.body by default', () => {
9
+ const before = document.body.children.length
10
+ const root = document.createElement('div')
11
+ document.body.appendChild(root)
12
+
13
+ const unmount = mount(h(Portal, { children: h('span', { id: 'pchild' }, 'modal') }), root)
14
+
15
+ // Wrapper appended directly to document.body (not inside `root`).
16
+ expect(document.body.children.length).toBe(before + 2) // root + portal wrapper
17
+ const wrapper = document.body.querySelector('#pchild')!.parentElement!
18
+ expect(wrapper).not.toBe(document.body)
19
+ expect(wrapper.tagName).toBe('DIV')
20
+ expect(wrapper.parentElement).toBe(document.body)
15
21
 
16
- it('defaults target to document.body when DOMLocation is not provided', () => {
17
- const child = h('div', null, 'content')
18
- const result = asVNode(Portal({ children: child }))
19
- const props = result.props as Record<string, unknown>
20
- expect(props.target).toBe(document.body)
22
+ unmount()
23
+ root.remove()
21
24
  })
22
25
 
23
- it('uses DOMLocation as target when provided', () => {
24
- const customTarget = document.createElement('div')
25
- const child = h('span', null, 'inside')
26
- const result = asVNode(Portal({ DOMLocation: customTarget, children: child }))
27
- const props = result.props as Record<string, unknown>
28
- expect(props.target).toBe(customTarget)
26
+ it('uses the supplied tag for the wrapper element', () => {
27
+ const root = document.createElement('div')
28
+ document.body.appendChild(root)
29
+
30
+ const unmount = mount(
31
+ h(Portal, { tag: 'section', children: h('span', { id: 'tagchild' }, 'x') }),
32
+ root,
33
+ )
34
+
35
+ const wrapper = document.body.querySelector('#tagchild')!.parentElement!
36
+ expect(wrapper.tagName).toBe('SECTION')
37
+
38
+ unmount()
39
+ root.remove()
29
40
  })
30
41
 
31
- it('passes children through to the CorePortal VNode', () => {
32
- const child = h('div', { class: 'modal' }, 'Modal content')
33
- const result = asVNode(Portal({ children: child }))
34
- const props = result.props as Record<string, unknown>
35
- expect(props.children).toBe(child)
42
+ it('appends the wrapper to DOMLocation when provided', () => {
43
+ const root = document.createElement('div')
44
+ const customTarget = document.createElement('article')
45
+ customTarget.id = 'custom-target'
46
+ document.body.appendChild(root)
47
+ document.body.appendChild(customTarget)
48
+
49
+ const unmount = mount(
50
+ h(Portal, { DOMLocation: customTarget, children: h('span', { id: 'cchild' }, 'inside') }),
51
+ root,
52
+ )
53
+
54
+ const wrapper = customTarget.querySelector('#cchild')!.parentElement!
55
+ expect(wrapper.parentElement).toBe(customTarget)
56
+
57
+ unmount()
58
+ root.remove()
59
+ customTarget.remove()
36
60
  })
37
61
 
38
- it('passes string children', () => {
39
- const result = asVNode(Portal({ children: 'text content' }))
40
- const props = result.props as Record<string, unknown>
41
- expect(props.children).toBe('text content')
62
+ it('renders children inside the wrapper', () => {
63
+ const root = document.createElement('div')
64
+ document.body.appendChild(root)
65
+
66
+ const unmount = mount(
67
+ h(Portal, { children: h('span', { id: 'inside-wrapper', class: 'modal' }, 'Modal') }),
68
+ root,
69
+ )
70
+
71
+ const child = document.body.querySelector('#inside-wrapper')!
72
+ expect(child.textContent).toBe('Modal')
73
+ const wrapper = child.parentElement!
74
+ expect(wrapper.parentElement).toBe(document.body)
75
+
76
+ unmount()
77
+ root.remove()
42
78
  })
43
79
 
44
- it('passes number children', () => {
45
- const result = asVNode(Portal({ children: 42 }))
46
- const props = result.props as Record<string, unknown>
47
- expect(props.children).toBe(42)
80
+ it('removes the wrapper from the DOM on unmount', () => {
81
+ const root = document.createElement('div')
82
+ document.body.appendChild(root)
83
+
84
+ const before = document.body.children.length
85
+ const unmount = mount(
86
+ h(Portal, { children: h('span', { id: 'cleanup-child' }, 'x') }),
87
+ root,
88
+ )
89
+ expect(document.body.children.length).toBe(before + 1) // wrapper added
90
+ const wrapper = document.body.querySelector('#cleanup-child')!.parentElement!
91
+ expect(wrapper.isConnected).toBe(true)
92
+
93
+ unmount()
94
+ expect(wrapper.isConnected).toBe(false)
95
+ expect(document.body.contains(wrapper)).toBe(false)
96
+
97
+ root.remove()
48
98
  })
49
99
 
50
- it('passes nested VNode children', () => {
51
- const nested = h('div', null, h('span', null, 'level 1'), h('span', null, 'level 2'))
52
- const result = asVNode(Portal({ children: nested }))
53
- const props = result.props as Record<string, unknown>
54
- const childVNode = asVNode(props.children)
55
- expect(childVNode.type).toBe('div')
56
- expect(childVNode.children).toHaveLength(2)
100
+ it('removes the wrapper from a custom DOMLocation on unmount', () => {
101
+ const root = document.createElement('div')
102
+ const customTarget = document.createElement('div')
103
+ document.body.appendChild(root)
104
+ document.body.appendChild(customTarget)
105
+
106
+ const unmount = mount(
107
+ h(Portal, { DOMLocation: customTarget, children: h('span', { id: 'cu' }, 'x') }),
108
+ root,
109
+ )
110
+ expect(customTarget.children.length).toBe(1)
111
+
112
+ unmount()
113
+ expect(customTarget.children.length).toBe(0)
114
+
115
+ root.remove()
116
+ customTarget.remove()
57
117
  })
58
- })
59
118
 
60
- describe('tag prop', () => {
61
- it('accepts tag prop without affecting output type', () => {
62
- const child = h('div', null, 'content')
63
- const result = asVNode(Portal({ tag: 'section', children: child }))
64
- // tag is accepted but not used output is still a Portal VNode
65
- expect(result.type).toBe(CorePortal)
119
+ it('isolates per-instance wrappers when multiple Portals share a DOMLocation', () => {
120
+ const root = document.createElement('div')
121
+ document.body.appendChild(root)
122
+
123
+ const u1 = mount(h(Portal, { children: h('span', { id: 'p1' }, 'A') }), root)
124
+ const u2 = mount(h(Portal, { children: h('span', { id: 'p2' }, 'B') }), root)
125
+
126
+ const w1 = document.body.querySelector('#p1')!.parentElement!
127
+ const w2 = document.body.querySelector('#p2')!.parentElement!
128
+ expect(w1).not.toBe(w2)
129
+ expect(w1.parentElement).toBe(document.body)
130
+ expect(w2.parentElement).toBe(document.body)
131
+
132
+ u1()
133
+ // unmounting one Portal removes only its wrapper, not the sibling's
134
+ expect(w1.isConnected).toBe(false)
135
+ expect(w2.isConnected).toBe(true)
136
+
137
+ u2()
138
+ expect(w2.isConnected).toBe(false)
139
+ root.remove()
66
140
  })
67
141
  })
68
142
 
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Regression: Wrapper used to silently drop `dangerouslySetInnerHTML`.
3
+ *
4
+ * Bug shape: `OWN_KEYS` listed `'dangerouslySetInnerHTML'`, so `splitProps`
5
+ * moved it into `own`. The Styled JSX call only spread `...commonProps`
6
+ * (built from `rest`) and never re-attached `own.dangerouslySetInnerHTML`.
7
+ * Both runtimes (`runtime-server` and `runtime-dom`) support the prop —
8
+ * the data was lost between Wrapper and the renderer.
9
+ *
10
+ * Two test layers:
11
+ *
12
+ * 1. **Mock-vnode tests** (this file's first describe block) — fast
13
+ * structural assertions against the vnode tree Wrapper returns. Catches
14
+ * the prop drop at the API surface where it originally happened.
15
+ *
16
+ * 2. **Real-h() mount tests** (second describe block) — uses real `h()` +
17
+ * `mount()` to exercise the full Element → Wrapper → Styled → DOM
18
+ * pipeline. Catches the prop drop wherever it might occur along the
19
+ * chain (Wrapper, Element, rocketstyle attrs HOC, runtime-dom prop
20
+ * application). This is the "safety net" pattern from
21
+ * .claude/rules/test-environment-parity.md — mock-vnode tests bypass
22
+ * the HOC + mount pipeline and CAN miss bugs that surface only when
23
+ * the real `h()` + mount path runs, exactly like PR #197's silent
24
+ * metadata drop. Always have both.
25
+ */
26
+ import { h, type VNode } from '@pyreon/core'
27
+ import { mount } from '@pyreon/runtime-dom'
28
+ import { describe, expect, it, vi } from 'vitest'
29
+
30
+ vi.mock('~/utils', () => ({
31
+ IS_DEVELOPMENT: false,
32
+ }))
33
+
34
+ import Wrapper from '../helpers/Wrapper/component'
35
+ import { Element } from '../Element'
36
+
37
+ const asVNode = (v: unknown) => v as VNode
38
+
39
+ describe('Wrapper — dangerouslySetInnerHTML forwarding (mock-vnode)', () => {
40
+ it('forwards dangerouslySetInnerHTML to the rendered Styled vnode (non-needsFix path)', () => {
41
+ const html = { __html: '<svg>x</svg>' }
42
+ const result = asVNode(
43
+ Wrapper({
44
+ tag: 'div',
45
+ dangerouslySetInnerHTML: html,
46
+ }),
47
+ )
48
+
49
+ // Bug-shape assertion: the prop must reach the rendered vnode.
50
+ // Pre-fix this is `undefined` → SVG is silently dropped.
51
+ expect(result.props.dangerouslySetInnerHTML).toBe(html)
52
+ })
53
+
54
+ it('drops children when dangerouslySetInnerHTML is present (mutually exclusive)', () => {
55
+ const html = { __html: '<svg>x</svg>' }
56
+ const result = asVNode(
57
+ Wrapper({
58
+ tag: 'div',
59
+ dangerouslySetInnerHTML: html,
60
+ // children would conflict — innerHTML wins.
61
+ children: 'should be dropped',
62
+ }),
63
+ )
64
+
65
+ // children slot must not coexist with innerHTML — runtime-server's
66
+ // and runtime-dom's prop pipeline both treat them as inner-content
67
+ // sources, and emitting both would result in either a malformed
68
+ // tree or innerHTML being overwritten by the children mount.
69
+ expect(result.children).toEqual([])
70
+ })
71
+
72
+ it('forwards dangerouslySetInnerHTML on the needsFix path (button/fieldset/legend)', () => {
73
+ // button/fieldset/legend take the two-layer flex fix path. innerHTML
74
+ // belongs on the inner styled node (where the actual content goes),
75
+ // NOT on the outer wrapper.
76
+ const html = { __html: '<span>label</span>' }
77
+ const result = asVNode(
78
+ Wrapper({
79
+ tag: 'button',
80
+ dangerouslySetInnerHTML: html,
81
+ }),
82
+ )
83
+
84
+ // The `needsFix` branch should NOT trigger when innerHTML is set
85
+ // (innerHTML replaces all children, including the inner flex-fix
86
+ // layer). The simplest correct behavior: bypass needsFix when
87
+ // innerHTML is present and forward the prop on the single Styled.
88
+ expect(result.props.dangerouslySetInnerHTML).toBe(html)
89
+ })
90
+ })
91
+
92
+ // Real-h() mount tests — parallel coverage that runs the full pipeline.
93
+ // Element uses Wrapper internally; mounting Element with
94
+ // `dangerouslySetInnerHTML` exercises every layer the bug could surface
95
+ // at: Element's split → Wrapper → Styled → runtime-dom's prop application.
96
+ // happy-dom is the test environment; `dangerouslySetInnerHTML` translates
97
+ // to `el.innerHTML = ...` which happy-dom handles natively.
98
+ describe('Wrapper — dangerouslySetInnerHTML forwarding (real h() + mount)', () => {
99
+ it('Element with dangerouslySetInnerHTML actually injects HTML into the DOM (non-needsFix tag)', () => {
100
+ const root = document.createElement('div')
101
+ document.body.appendChild(root)
102
+
103
+ const unmount = mount(
104
+ h(Element, {
105
+ tag: 'div',
106
+ 'data-testid': 'innerhtml-host',
107
+ dangerouslySetInnerHTML: { __html: '<svg data-marker="real-h-svg">x</svg>' },
108
+ }),
109
+ root,
110
+ )
111
+
112
+ // The structural assertion: SVG element exists in the rendered DOM.
113
+ // Pre-fix Wrapper dropped the prop → no SVG → null query result.
114
+ const svg = root.querySelector('[data-marker="real-h-svg"]')
115
+ expect(svg).not.toBeNull()
116
+ expect(svg?.tagName.toLowerCase()).toBe('svg')
117
+
118
+ unmount()
119
+ root.remove()
120
+ })
121
+
122
+ it('Element with dangerouslySetInnerHTML on a needsFix tag (button) still injects HTML', () => {
123
+ // button is a needsFix tag (two-layer flex fix). The Wrapper branch
124
+ // must bypass the two-layer fix when innerHTML is present, OR forward
125
+ // innerHTML to the right layer. Either way the rendered DOM must
126
+ // contain the user-supplied HTML.
127
+ const root = document.createElement('div')
128
+ document.body.appendChild(root)
129
+
130
+ const unmount = mount(
131
+ h(Element, {
132
+ tag: 'button',
133
+ 'data-testid': 'innerhtml-button',
134
+ dangerouslySetInnerHTML: { __html: '<span data-marker="real-h-button-label">click me</span>' },
135
+ }),
136
+ root,
137
+ )
138
+
139
+ const span = root.querySelector('[data-marker="real-h-button-label"]')
140
+ expect(span).not.toBeNull()
141
+ expect(span?.textContent).toBe('click me')
142
+
143
+ unmount()
144
+ root.remove()
145
+ })
146
+
147
+ it('children passed alongside dangerouslySetInnerHTML are dropped (innerHTML wins)', () => {
148
+ // Bug shape: if Wrapper's `own.children` leaks into the rendered vnode
149
+ // alongside `dangerouslySetInnerHTML`, runtime-dom would either mount
150
+ // children INTO the innerHTML-populated element (overwriting), or land
151
+ // both side-by-side. The contract is that innerHTML wins and children
152
+ // are dropped. Verifying at the DOM level catches both failure shapes.
153
+ const root = document.createElement('div')
154
+ document.body.appendChild(root)
155
+
156
+ const unmount = mount(
157
+ h(
158
+ Element,
159
+ {
160
+ tag: 'div',
161
+ 'data-testid': 'innerhtml-with-children',
162
+ dangerouslySetInnerHTML: { __html: '<i data-marker="real-h-winner">html wins</i>' },
163
+ },
164
+ 'this child text should NOT appear',
165
+ ),
166
+ root,
167
+ )
168
+
169
+ const host = root.querySelector('[data-testid="innerhtml-with-children"]')!
170
+ expect(host.querySelector('[data-marker="real-h-winner"]')).not.toBeNull()
171
+ // The child string must not appear anywhere in the rendered host.
172
+ expect(host.textContent).not.toContain('this child text should NOT appear')
173
+ expect(host.textContent).toContain('html wins')
174
+
175
+ unmount()
176
+ root.remove()
177
+ })
178
+ })
@@ -82,4 +82,51 @@ describe('@pyreon/elements browser smoke', () => {
82
82
  })
83
83
  }
84
84
  })
85
+
86
+ // Regression: Wrapper used to silently drop dangerouslySetInnerHTML.
87
+ // The unit test asserts Wrapper forwards the prop on the rendered
88
+ // vnode; this real-Chromium test asserts the SVG actually appears in
89
+ // the DOM after mount — the user-visible bug shape was "renders empty
90
+ // <div></div> instead of inlined SVG".
91
+ describe('dangerouslySetInnerHTML — real-Chromium DOM proof', () => {
92
+ it('inlines an SVG via Element + dangerouslySetInnerHTML', () => {
93
+ const { container, unmount } = mountInBrowser(
94
+ <Element
95
+ tag="div"
96
+ data-id="logo"
97
+ dangerouslySetInnerHTML={{
98
+ __html: '<svg viewBox="0 0 10 10"><rect width="10" height="10" /></svg>',
99
+ }}
100
+ />,
101
+ )
102
+ const root = container.querySelector('[data-id="logo"]')
103
+ expect(root).toBeTruthy()
104
+ // The SVG must actually be in the DOM, not lost between Wrapper and
105
+ // the renderer. Pre-fix this assertion failed: container had
106
+ // <div data-id="logo"></div> with no children.
107
+ const svg = root?.querySelector('svg')
108
+ expect(svg).toBeTruthy()
109
+ expect(svg?.tagName.toLowerCase()).toBe('svg')
110
+ expect(svg?.querySelector('rect')).toBeTruthy()
111
+ unmount()
112
+ })
113
+
114
+ it('inlines markup on a button (needsFix path)', () => {
115
+ const { container, unmount } = mountInBrowser(
116
+ <Element
117
+ tag="button"
118
+ data-id="btn"
119
+ dangerouslySetInnerHTML={{ __html: '<strong>Save</strong>' }}
120
+ />,
121
+ )
122
+ const btn = container.querySelector('[data-id="btn"]')
123
+ expect(btn).toBeTruthy()
124
+ // The bold text must reach the DOM. The needsFix gate's existing
125
+ // `!own.dangerouslySetInnerHTML` clause skips the two-layer fix,
126
+ // so this falls into the !needsFix branch and the fix's
127
+ // `if (innerHTML)` path takes over.
128
+ expect(btn?.querySelector('strong')?.textContent).toBe('Save')
129
+ unmount()
130
+ })
131
+ })
85
132
  })
@@ -0,0 +1,121 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { styles } from '../helpers/Wrapper/styled'
3
+
4
+ /**
5
+ * Regression test for the responsive `block` cascade bug (mirrors vitus-labs's
6
+ * "couple of fixes" PR #121).
7
+ *
8
+ * Scenario: a responsive theme like `block: [true, false, true]` runs the
9
+ * styles callback once per breakpoint with a single-value `t.block`. The bug
10
+ * was that `align-self`, `flex`/`width` etc. were emitted ONLY when
11
+ * `t.block` was truthy — so the breakpoint where `t.block` is false emitted
12
+ * nothing, leaving the previous breakpoint's `align-self: stretch` cascading
13
+ * through the mobile-first @media query.
14
+ *
15
+ * Fix: always emit a value for these properties (truthy → stretch/100%,
16
+ * falsy → auto/auto). The mobile-first @media cascade then resets cleanly
17
+ * when `block` flips false at a later breakpoint.
18
+ *
19
+ * The styles callback is called per-breakpoint with the per-breakpoint theme
20
+ * already resolved to a single value — so testing it directly with two
21
+ * scalar themes is enough to lock in the always-emit contract.
22
+ */
23
+
24
+ const identityCss = (strings: TemplateStringsArray, ...vals: unknown[]) => {
25
+ let r = ''
26
+ for (let i = 0; i < strings.length; i++) {
27
+ r += strings[i]
28
+ if (i < vals.length) {
29
+ const v = vals[i]
30
+ // Mirror styled-components / styler interpolation: drop falsy
31
+ // interpolations, stringify the rest. CSSResult instances (from the
32
+ // alignContent / extendCss helpers) coerce via their toString.
33
+ if (v === false || v == null) continue
34
+ r += String(v)
35
+ }
36
+ }
37
+ return r
38
+ }
39
+
40
+ const renderAt = (theme: Record<string, unknown>): string =>
41
+ String(styles({ theme, css: identityCss }))
42
+
43
+ describe('Wrapper styles — responsive block cascade reset', () => {
44
+ it('emits stretch/100%/flex when block is true', () => {
45
+ const out = renderAt({
46
+ block: true,
47
+ direction: 'inline',
48
+ alignX: 'left',
49
+ alignY: 'center',
50
+ })
51
+ expect(out).toContain('align-self: stretch')
52
+ expect(out).toContain('width: 100%')
53
+ expect(out).toContain('display: flex')
54
+ })
55
+
56
+ it('emits auto/auto/inline-flex reset when block is false (key fix)', () => {
57
+ const out = renderAt({
58
+ block: false,
59
+ direction: 'inline',
60
+ alignX: 'left',
61
+ alignY: 'center',
62
+ })
63
+ // The fix: these properties MUST be emitted with reset values so the
64
+ // mobile-first @media cascade doesn't leak `align-self: stretch` from a
65
+ // smaller breakpoint where block was true.
66
+ expect(out).toContain('align-self: auto')
67
+ expect(out).toContain('width: auto')
68
+ expect(out).toContain('display: inline-flex')
69
+ })
70
+
71
+ it('emits height: 100% when alignY is "block", auto otherwise', () => {
72
+ const blockY = renderAt({
73
+ block: false,
74
+ direction: 'inline',
75
+ alignX: 'left',
76
+ alignY: 'block',
77
+ })
78
+ expect(blockY).toContain('height: 100%')
79
+
80
+ const nonBlockY = renderAt({
81
+ block: false,
82
+ direction: 'inline',
83
+ alignX: 'left',
84
+ alignY: 'center',
85
+ })
86
+ expect(nonBlockY).toContain('height: auto')
87
+ })
88
+
89
+ it('does not emit display when childFix is set (parent split)', () => {
90
+ const out = renderAt({
91
+ block: true,
92
+ childFix: true,
93
+ direction: 'inline',
94
+ alignX: 'left',
95
+ alignY: 'center',
96
+ })
97
+ // childFix branch handles its own display rules outside the responsive
98
+ // styles callback.
99
+ expect(out).not.toContain('display: flex;')
100
+ expect(out).not.toContain('display: inline-flex;')
101
+ })
102
+
103
+ it('emits parentFix flex-direction: column only when parentFix is set', () => {
104
+ const withParentFix = renderAt({
105
+ block: true,
106
+ parentFix: true,
107
+ direction: 'inline',
108
+ alignX: 'left',
109
+ alignY: 'center',
110
+ })
111
+ expect(withParentFix).toContain('flex-direction: column')
112
+
113
+ const withoutParentFix = renderAt({
114
+ block: true,
115
+ direction: 'inline',
116
+ alignX: 'left',
117
+ alignY: 'center',
118
+ })
119
+ expect(withoutParentFix).not.toContain('flex-direction: column')
120
+ })
121
+ })
@@ -10,7 +10,15 @@
10
10
  import type { VNode, VNodeChild } from '@pyreon/core'
11
11
  import { Fragment } from '@pyreon/core'
12
12
  import { isEmpty, render } from '@pyreon/ui-core'
13
- import type { ExtendedProps, ObjectValue, Props, SimpleValue } from './types'
13
+ import type {
14
+ ChildrenProps,
15
+ ExtendedProps,
16
+ LooseProps,
17
+ ObjectProps,
18
+ ObjectValue,
19
+ SimpleProps,
20
+ SimpleValue,
21
+ } from './types'
14
22
 
15
23
  type ClassifiedData =
16
24
  | { type: 'simple'; data: SimpleValue[] }
@@ -70,7 +78,7 @@ const attachItemProps: AttachItemProps = ({ i, length }: { i: number; length: nu
70
78
  }
71
79
  }
72
80
 
73
- const Component = (props: Props) => {
81
+ const Component = (props: LooseProps) => {
74
82
  const {
75
83
  itemKey,
76
84
  valueName,
@@ -246,7 +254,50 @@ const Component = (props: Props) => {
246
254
  return renderItems()
247
255
  }
248
256
 
249
- export default Object.assign(Component, {
257
+ // ---------------------------------------------------------------------------
258
+ // Public callable type — overloads expose the generic `<T>` API at the JSX
259
+ // boundary while the impl stays loose-typed. TS picks the matching overload
260
+ // based on the props object passed:
261
+ //
262
+ // <Iterator data={['a','b']} valueName="text" component={Item} />
263
+ // ^ T inferred as string → SimpleProps<string> overload selected
264
+ //
265
+ // <Iterator data={users} component={UserCard} />
266
+ // ^ T inferred as User → ObjectProps<User> overload selected
267
+ //
268
+ // <Iterator>{...}</Iterator> → ChildrenProps overload selected
269
+ // ---------------------------------------------------------------------------
270
+ export interface IteratorComponent {
271
+ // T is inferred from the `data` prop at the JSX site — no explicit
272
+ // generic argument needed. Order matters: SimpleProps first (matches
273
+ // `data: SimpleValue[]`), then ObjectProps (object[]), then ChildrenProps,
274
+ // then a LooseProps fallback.
275
+ //
276
+ // The narrow overloads (Simple / Object / Children) drive per-mode T
277
+ // inference and stricter compile-time errors for direct callers (e.g.
278
+ // `valueName` required for primitive arrays, forbidden for object arrays).
279
+ // The LooseProps fallback exists for forwarding patterns where the props
280
+ // type is a wide union that doesn't bind to any single narrow overload —
281
+ // notably `Partial<(typeof Wrapper)['$$types']>` spread back into the JSX
282
+ // site after `@pyreon/rocketstyle`'s 4-overload-aware `ExtractProps`
283
+ // distributes the union across all of Iterator's call signatures. Without
284
+ // the loose binding home, the wide union has nowhere to land and TS
285
+ // reports "no overload matches this call" at every forwarding site.
286
+ //
287
+ // Direct callers still see the strict per-mode errors — the loose fallback
288
+ // only fires when none of the three narrow overloads match.
289
+ <T extends SimpleValue>(props: SimpleProps<T>): VNodeChild
290
+ <T extends ObjectValue>(props: ObjectProps<T>): VNodeChild
291
+ (props: ChildrenProps): VNodeChild
292
+ (props: LooseProps): VNodeChild
293
+ isIterator: true
294
+ RESERVED_PROPS: typeof RESERVED_PROPS
295
+ displayName?: string
296
+ }
297
+
298
+ const Iterator = Object.assign(Component, {
250
299
  isIterator: true as const,
251
300
  RESERVED_PROPS,
252
- })
301
+ }) as unknown as IteratorComponent
302
+
303
+ export default Iterator
@@ -1,14 +1,30 @@
1
1
  import component from './component'
2
2
  import type {
3
+ ChildrenProps,
3
4
  ElementType,
4
5
  ExtendedProps,
6
+ LooseProps,
5
7
  MaybeNull,
8
+ ObjectProps,
6
9
  ObjectValue,
7
10
  Props,
8
11
  PropsCallback,
12
+ SimpleProps,
9
13
  SimpleValue,
10
14
  } from './types'
11
15
 
12
- export type { ElementType, ExtendedProps, MaybeNull, ObjectValue, Props, PropsCallback, SimpleValue }
16
+ export type {
17
+ ChildrenProps,
18
+ ElementType,
19
+ ExtendedProps,
20
+ LooseProps,
21
+ MaybeNull,
22
+ ObjectProps,
23
+ ObjectValue,
24
+ Props,
25
+ PropsCallback,
26
+ SimpleProps,
27
+ SimpleValue,
28
+ }
13
29
 
14
30
  export default component