@pyreon/elements 0.24.2 → 0.24.3

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
@@ -91,8 +91,15 @@ const StyledComponent = styled$2(component$1, { layer: "elements" })`
91
91
  * gap, and equalCols styling props to the underlying styled component.
92
92
  * Adds a `data-pyr-element` attribute in development for debugging.
93
93
  *
94
- * Children are rendered via core `render()`.
94
+ * Children are rendered via core `render()`, with function-valued
95
+ * children unwrapped inside a reactive accessor so the compound-layout
96
+ * paths in `Element` keep `content={() => <X />}` reactivity intact
97
+ * (mirrors the `resolveSlot` helper in `Element/component.tsx`).
95
98
  */
99
+ const resolveSlot = (value) => {
100
+ if (typeof value === "function") return value();
101
+ return render(value);
102
+ };
96
103
  const Component$9 = (props) => {
97
104
  const [own, rest] = splitProps(props, [
98
105
  "contentType",
@@ -123,7 +130,7 @@ const Component$9 = (props) => {
123
130
  $element: stylingProps,
124
131
  ...debugProps,
125
132
  ...rest,
126
- children: render(own.children)
133
+ children: () => resolveSlot(own.children)
127
134
  });
128
135
  };
129
136
 
@@ -508,6 +515,10 @@ const Component = (props) => {
508
515
  const shouldBeEmpty = !!rest.dangerouslySetInnerHTML || getShouldBeEmpty(own.tag);
509
516
  const isSimpleElement = !own.beforeContent && !own.afterContent;
510
517
  const getChildren = () => own.children ?? own.content ?? own.label;
518
+ const resolveSlot = (value) => {
519
+ if (typeof value === "function") return value();
520
+ return render(value);
521
+ };
511
522
  const isInline = isInlineElement(own.tag);
512
523
  const SUB_TAG = isInline ? "span" : void 0;
513
524
  let wrapperDirection = own.direction;
@@ -563,13 +574,13 @@ const Component = (props) => {
563
574
  equalCols: own.equalCols,
564
575
  extraStyles: own.css
565
576
  }),
566
- children: render(getChildren())
577
+ children: () => resolveSlot(getChildren())
567
578
  });
568
579
  if (isSimpleElement) return /* @__PURE__ */ jsx(Wrapper_default, {
569
580
  ...rest,
570
581
  ...WRAPPER_PROPS,
571
582
  isInline,
572
- children: render(getChildren())
583
+ children: () => resolveSlot(getChildren())
573
584
  });
574
585
  return /* @__PURE__ */ jsxs(Wrapper_default, {
575
586
  ...rest,
@@ -586,7 +597,7 @@ const Component = (props) => {
586
597
  alignY: beforeContentAlignY,
587
598
  equalCols: own.equalCols,
588
599
  gap: own.gap,
589
- children: own.beforeContent
600
+ children: () => resolveSlot(own.beforeContent)
590
601
  }),
591
602
  /* @__PURE__ */ jsx(Content_default, {
592
603
  tag: SUB_TAG,
@@ -597,7 +608,7 @@ const Component = (props) => {
597
608
  alignX: contentAlignX,
598
609
  alignY: contentAlignY,
599
610
  equalCols: own.equalCols,
600
- children: getChildren()
611
+ children: () => resolveSlot(getChildren())
601
612
  }),
602
613
  own.afterContent && /* @__PURE__ */ jsx(Content_default, {
603
614
  tag: SUB_TAG,
@@ -609,7 +620,7 @@ const Component = (props) => {
609
620
  alignY: afterContentAlignY,
610
621
  equalCols: own.equalCols,
611
622
  gap: own.gap,
612
- children: own.afterContent
623
+ children: () => resolveSlot(own.afterContent)
613
624
  })
614
625
  ]
615
626
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/elements",
3
- "version": "0.24.2",
3
+ "version": "0.24.3",
4
4
  "description": "Foundational UI components for Pyreon",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,12 +42,12 @@
42
42
  "typecheck": "tsc --noEmit"
43
43
  },
44
44
  "devDependencies": {
45
- "@pyreon/core": "^0.24.2",
45
+ "@pyreon/core": "^0.24.3",
46
46
  "@pyreon/manifest": "0.13.1",
47
- "@pyreon/reactivity": "^0.24.2",
48
- "@pyreon/runtime-dom": "^0.24.2",
47
+ "@pyreon/reactivity": "^0.24.3",
48
+ "@pyreon/runtime-dom": "^0.24.3",
49
49
  "@pyreon/test-utils": "^0.13.11",
50
- "@pyreon/typescript": "^0.24.2",
50
+ "@pyreon/typescript": "^0.24.3",
51
51
  "@vitest/browser-playwright": "^4.1.4",
52
52
  "@vitus-labs/tools-rolldown": "^2.4.0"
53
53
  },
@@ -55,9 +55,9 @@
55
55
  "node": ">= 22"
56
56
  },
57
57
  "dependencies": {
58
- "@pyreon/core": "^0.24.2",
59
- "@pyreon/reactivity": "^0.24.2",
60
- "@pyreon/ui-core": "^0.24.2",
61
- "@pyreon/unistyle": "^0.24.2"
58
+ "@pyreon/core": "^0.24.3",
59
+ "@pyreon/reactivity": "^0.24.3",
60
+ "@pyreon/ui-core": "^0.24.3",
61
+ "@pyreon/unistyle": "^0.24.3"
62
62
  }
63
63
  }
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { onMount, splitProps } from '@pyreon/core'
11
+ import type { VNodeChildAtom } from '@pyreon/core'
11
12
  import { render } from '@pyreon/ui-core'
12
13
  import { PKG_NAME } from '../constants'
13
14
  import { Content, Wrapper } from '../helpers'
@@ -102,6 +103,29 @@ const Component: PyreonElement = (props) => {
102
103
  // Reading own.content via ?? at setup would capture the value once.
103
104
  const getChildren = () => own.children ?? own.content ?? own.label
104
105
 
106
+ // Resolve a slot value INSIDE a reactive accessor. If the consumer passed a
107
+ // function-returning-VNode (e.g. `content={() => <Icon name={signal()} />}`),
108
+ // unwrap it by calling — its body's signal reads are then tracked by the
109
+ // enclosing mountReactive effect, and the slot re-renders on signal change.
110
+ // Static VNodes / strings / null pass through unchanged to `render()`.
111
+ //
112
+ // Pre-fix: `render(() => <X/>)` treated the function as a COMPONENT and
113
+ // called `h(fn, {})` — the component body ran once at mount, future signal
114
+ // changes inside the body were never observed. Wrapping the JSX position in
115
+ // `{() => resolveSlot(...)}` plus unwrapping function values here is what
116
+ // makes `content={() => ...}` reactive (matches the
117
+ // `{() => show() ? <A/> : null}` pattern documented at
118
+ // runtime-dom/src/nodes.ts:90-93).
119
+ // Return type is the RESOLVED atom (VNodeChildAtom | VNodeChildAtom[]) —
120
+ // never a nested accessor — so the enclosing `() => resolveSlot(...)` IS
121
+ // a valid VNodeChildAccessor in the JSX child position.
122
+ const resolveSlot = (value: unknown): VNodeChildAtom | VNodeChildAtom[] => {
123
+ if (typeof value === 'function') {
124
+ return (value as () => VNodeChildAtom | VNodeChildAtom[])()
125
+ }
126
+ return render(value as Parameters<typeof render>[0]) as VNodeChildAtom | VNodeChildAtom[]
127
+ }
128
+
105
129
  const isInline = isInlineElement(own.tag)
106
130
  const SUB_TAG = isInline ? 'span' : undefined
107
131
 
@@ -201,7 +225,7 @@ const Component: PyreonElement = (props) => {
201
225
  extraStyles: own.css,
202
226
  })}
203
227
  >
204
- {render(getChildren())}
228
+ {() => resolveSlot(getChildren())}
205
229
  </WrapperStyled>
206
230
  )
207
231
  }
@@ -209,7 +233,7 @@ const Component: PyreonElement = (props) => {
209
233
  if (isSimpleElement) {
210
234
  return (
211
235
  <Wrapper {...rest} {...WRAPPER_PROPS} isInline={isInline}>
212
- {render(getChildren())}
236
+ {() => resolveSlot(getChildren())}
213
237
  </Wrapper>
214
238
  )
215
239
  }
@@ -228,7 +252,7 @@ const Component: PyreonElement = (props) => {
228
252
  equalCols={own.equalCols}
229
253
  gap={own.gap}
230
254
  >
231
- {own.beforeContent}
255
+ {() => resolveSlot(own.beforeContent)}
232
256
  </Content>
233
257
  )}
234
258
 
@@ -242,7 +266,7 @@ const Component: PyreonElement = (props) => {
242
266
  alignY={contentAlignY}
243
267
  equalCols={own.equalCols}
244
268
  >
245
- {getChildren()}
269
+ {() => resolveSlot(getChildren())}
246
270
  </Content>
247
271
 
248
272
  {own.afterContent && (
@@ -257,7 +281,7 @@ const Component: PyreonElement = (props) => {
257
281
  equalCols={own.equalCols}
258
282
  gap={own.gap}
259
283
  >
260
- {own.afterContent}
284
+ {() => resolveSlot(own.afterContent)}
261
285
  </Content>
262
286
  )}
263
287
  </Wrapper>
@@ -76,9 +76,17 @@ describe('Content component', () => {
76
76
  expect(result.props['data-pyr-element']).toBe('after')
77
77
  })
78
78
 
79
- it('passes children through render()', () => {
79
+ it('passes children through render() when the slot accessor is invoked', () => {
80
+ // Content wraps its children in a reactive accessor `() => resolveSlot(...)`
81
+ // — render() is no longer called synchronously at component setup. The
82
+ // accessor is invoked by the runtime when the JSX child position mounts;
83
+ // here we invoke it directly to assert the wiring. The
84
+ // function-unwrap-then-render shape is what keeps `content={() => <X/>}`
85
+ // slot reactivity working (see Element-slot-reactivity.browser.test.tsx).
80
86
  const children = 'Some text'
81
- Content({ children })
87
+ const result = asVNode(Content({ children }))
88
+ expect(typeof result.props.children).toBe('function')
89
+ ;(result.props.children as () => unknown)()
82
90
  expect(mocks.render).toHaveBeenCalledWith(children)
83
91
  })
84
92
 
@@ -0,0 +1,152 @@
1
+ /** @jsxImportSource @pyreon/core */
2
+ /**
3
+ * Regression specs for the Element slot reactivity bug.
4
+ *
5
+ * Pre-fix: `<Element content={() => <Icon name={signal()} />}>` evaluated the
6
+ * function once at mount and baked in the result. Signal changes inside the
7
+ * function body did NOT cause the slot to re-render — even though the
8
+ * `getChildren` helper in Element/component.tsx had a getter shape intended
9
+ * to preserve reactivity.
10
+ *
11
+ * Root cause: the JSX child position read the resolved slot value at
12
+ * component-setup time. The runtime's `mountChild` reactive-function-child
13
+ * handling (`mountReactive`) was never reached because the function was
14
+ * passed to `render()` which treated it as a component (one-shot mount),
15
+ * not as a reactive accessor.
16
+ *
17
+ * Fix: wrap the JSX child position in `{() => ...}` so it becomes a
18
+ * reactive accessor that mountChild routes through mountReactive.
19
+ * Slot values that are themselves functions get unwrapped (called) inside
20
+ * the accessor so their body's signal reads are tracked by the effect.
21
+ *
22
+ * Bisect-verify-with-restore: revert the wrap → these tests fail with
23
+ * stuck slot content; restore → tests pass.
24
+ */
25
+ import { describe, expect, it } from 'vitest'
26
+ import { signal } from '@pyreon/reactivity'
27
+ import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
28
+ import { Element } from '../Element'
29
+
30
+ describe('Element slot reactivity — function-valued slot props', () => {
31
+ it('content={() => <X />} re-renders when a signal inside the function body changes', async () => {
32
+ const dark = signal(false)
33
+ const { container, unmount } = mountInBrowser(
34
+ <Element
35
+ tag="div"
36
+ data-id="root"
37
+ content={() => <span data-id="icon">{dark() ? 'moon' : 'sun'}</span>}
38
+ />,
39
+ )
40
+
41
+ expect(container.querySelector('[data-id="icon"]')?.textContent).toBe('sun')
42
+ dark.set(true)
43
+ await flush()
44
+ expect(container.querySelector('[data-id="icon"]')?.textContent).toBe('moon')
45
+ dark.set(false)
46
+ await flush()
47
+ expect(container.querySelector('[data-id="icon"]')?.textContent).toBe('sun')
48
+ unmount()
49
+ })
50
+
51
+ it('beforeContent={() => <X />} re-renders when a signal inside changes', async () => {
52
+ const count = signal(0)
53
+ const { container, unmount } = mountInBrowser(
54
+ <Element
55
+ tag="div"
56
+ data-id="root"
57
+ beforeContent={() => <span data-id="badge">{`#${count()}`}</span>}
58
+ content={<span data-id="main">main</span>}
59
+ />,
60
+ )
61
+
62
+ expect(container.querySelector('[data-id="badge"]')?.textContent).toBe('#0')
63
+ count.set(5)
64
+ await flush()
65
+ expect(container.querySelector('[data-id="badge"]')?.textContent).toBe('#5')
66
+ unmount()
67
+ })
68
+
69
+ it('afterContent={() => <X />} re-renders when a signal inside changes', async () => {
70
+ const tag = signal('draft')
71
+ const { container, unmount } = mountInBrowser(
72
+ <Element
73
+ tag="div"
74
+ data-id="root"
75
+ content={<span data-id="main">main</span>}
76
+ afterContent={() => <span data-id="status">{tag()}</span>}
77
+ />,
78
+ )
79
+
80
+ expect(container.querySelector('[data-id="status"]')?.textContent).toBe('draft')
81
+ tag.set('published')
82
+ await flush()
83
+ expect(container.querySelector('[data-id="status"]')?.textContent).toBe('published')
84
+ unmount()
85
+ })
86
+
87
+ it('static VNode content (non-function) still works unchanged', () => {
88
+ // Regression guard for the static path — no function unwrap should
89
+ // happen here.
90
+ const { container, unmount } = mountInBrowser(
91
+ <Element tag="div" content={<span data-id="static">hello</span>} />,
92
+ )
93
+ expect(container.querySelector('[data-id="static"]')?.textContent).toBe('hello')
94
+ unmount()
95
+ })
96
+
97
+ it('static beforeContent + afterContent still render (compound path)', () => {
98
+ const { container, unmount } = mountInBrowser(
99
+ <Element
100
+ tag="div"
101
+ beforeContent={<span data-id="b">before</span>}
102
+ content={<span data-id="c">main</span>}
103
+ afterContent={<span data-id="a">after</span>}
104
+ />,
105
+ )
106
+ expect(container.querySelector('[data-id="b"]')?.textContent).toBe('before')
107
+ expect(container.querySelector('[data-id="c"]')?.textContent).toBe('main')
108
+ expect(container.querySelector('[data-id="a"]')?.textContent).toBe('after')
109
+ unmount()
110
+ })
111
+
112
+ it('null slot stays unrendered; flipping a signal can introduce it', async () => {
113
+ const show = signal(false)
114
+ const { container, unmount } = mountInBrowser(
115
+ <Element
116
+ tag="div"
117
+ data-id="root"
118
+ content={() => (show() ? <span data-id="present">shown</span> : null)}
119
+ />,
120
+ )
121
+ expect(container.querySelector('[data-id="present"]')).toBeNull()
122
+ show.set(true)
123
+ await flush()
124
+ expect(container.querySelector('[data-id="present"]')?.textContent).toBe('shown')
125
+ show.set(false)
126
+ await flush()
127
+ expect(container.querySelector('[data-id="present"]')).toBeNull()
128
+ unmount()
129
+ })
130
+
131
+ it('children prop (priority over content) — function form is reactive in compound layout', async () => {
132
+ // children takes priority over content per getChildren's `??` chain.
133
+ // Compound layout (when beforeContent OR afterContent exists) routes
134
+ // through a different render path than the simple-element fast path —
135
+ // both must handle function children reactively.
136
+ const text = signal('first')
137
+ const { container, unmount } = mountInBrowser(
138
+ <Element
139
+ tag="div"
140
+ beforeContent={<span data-id="b">b</span>}
141
+ afterContent={<span data-id="a">a</span>}
142
+ >
143
+ {() => <span data-id="kid">{text()}</span>}
144
+ </Element>,
145
+ )
146
+ expect(container.querySelector('[data-id="kid"]')?.textContent).toBe('first')
147
+ text.set('second')
148
+ await flush()
149
+ expect(container.querySelector('[data-id="kid"]')?.textContent).toBe('second')
150
+ unmount()
151
+ })
152
+ })
@@ -137,7 +137,13 @@ describe('Element', () => {
137
137
  // Simple element fast path — passes children as a single value, not a
138
138
  // 3-slot array wrapping falsy beforeContent/afterContent. This avoids
139
139
  // 2 extra mountChild calls per Element in the common case.
140
- expect(result.props.children).toBe('hello')
140
+ //
141
+ // Children are wrapped in a reactive accessor (`() => resolveSlot(...)`)
142
+ // so function-valued slot props (e.g. `content={() => <X />}`) stay
143
+ // reactive — see `Element-slot-reactivity.browser.test.tsx`. The
144
+ // accessor's RESOLVED value is the string `'hello'`.
145
+ expect(typeof result.props.children).toBe('function')
146
+ expect((result.props.children as () => unknown)()).toBe('hello')
141
147
  })
142
148
 
143
149
  it('passes block prop to Wrapper', () => {
@@ -606,8 +612,11 @@ describe('Element', () => {
606
612
  it('prefers children over content', () => {
607
613
  const result = asVNode(Element({ children: 'child', content: 'alt' }))
608
614
  // Simple-element fast path returns children directly. The fallback
609
- // chain (children → content → label) is exercised inside getChildren().
610
- expect(result.props.children).toBe('child')
615
+ // chain (children → content → label) is exercised inside getChildren(),
616
+ // which runs INSIDE the reactive accessor wrap — so invoking the
617
+ // accessor reveals the resolved value.
618
+ expect(typeof result.props.children).toBe('function')
619
+ expect((result.props.children as () => unknown)()).toBe('child')
611
620
  })
612
621
 
613
622
  it('falls back to content when no children', () => {
@@ -4,14 +4,28 @@
4
4
  * gap, and equalCols styling props to the underlying styled component.
5
5
  * Adds a `data-pyr-element` attribute in development for debugging.
6
6
  *
7
- * Children are rendered via core `render()`.
7
+ * Children are rendered via core `render()`, with function-valued
8
+ * children unwrapped inside a reactive accessor so the compound-layout
9
+ * paths in `Element` keep `content={() => <X />}` reactivity intact
10
+ * (mirrors the `resolveSlot` helper in `Element/component.tsx`).
8
11
  */
9
12
  import { splitProps } from '@pyreon/core'
13
+ import type { VNodeChildAtom } from '@pyreon/core'
10
14
  import { render } from '@pyreon/ui-core'
11
15
  import { IS_DEVELOPMENT } from '../../utils'
12
16
  import Styled from './styled'
13
17
  import type { Props } from './types'
14
18
 
19
+ // Return type is the RESOLVED atom — see the matching helper in
20
+ // Element/component.tsx for the rationale (keeps `() => resolveSlot(...)`
21
+ // a valid VNodeChildAccessor at the JSX child position).
22
+ const resolveSlot = (value: unknown): VNodeChildAtom | VNodeChildAtom[] => {
23
+ if (typeof value === 'function') {
24
+ return (value as () => VNodeChildAtom | VNodeChildAtom[])()
25
+ }
26
+ return render(value as Parameters<typeof render>[0]) as VNodeChildAtom | VNodeChildAtom[]
27
+ }
28
+
15
29
  const Component = (props: Partial<Props>) => {
16
30
  const [own, rest] = splitProps(props, [
17
31
  'contentType',
@@ -45,7 +59,7 @@ const Component = (props: Partial<Props>) => {
45
59
 
46
60
  return (
47
61
  <Styled as={own.tag} $contentType={own.contentType} $element={stylingProps} {...debugProps} {...rest}>
48
- {render(own.children)}
62
+ {() => resolveSlot(own.children)}
49
63
  </Styled>
50
64
  )
51
65
  }