@pyreon/elements 0.24.2 → 0.24.4
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 +67 -7
- package/package.json +9 -9
- package/src/Element/component.tsx +48 -6
- package/src/__tests__/Content.test.tsx +10 -2
- package/src/__tests__/Element-slot-reactivity.browser.test.tsx +152 -0
- package/src/__tests__/Element.test.ts +12 -3
- package/src/__tests__/slot-component-reference.test.tsx +157 -0
- package/src/helpers/Content/component.tsx +25 -3
- package/src/helpers/isPyreonComponent.ts +46 -0
package/lib/index.js
CHANGED
|
@@ -11,6 +11,49 @@ const PKG_NAME = "@pyreon/elements";
|
|
|
11
11
|
//#region src/utils.ts
|
|
12
12
|
const IS_DEVELOPMENT = process.env.NODE_ENV !== "production";
|
|
13
13
|
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region src/helpers/isPyreonComponent.ts
|
|
16
|
+
/**
|
|
17
|
+
* Detect whether a function value is a framework component (created via
|
|
18
|
+
* `rocketstyle()` or one of `@pyreon/elements`' component factories), as
|
|
19
|
+
* opposed to a plain reactive-accessor function.
|
|
20
|
+
*
|
|
21
|
+
* Used by `Element` / `Content` `resolveSlot` to discriminate between
|
|
22
|
+
* `beforeContent={Header}` — component-reference shorthand, MUST mount
|
|
23
|
+
* as `h(Component, null)` so the framework HOC's
|
|
24
|
+
* `removeUndefinedProps(props)` / `splitProps(props)` get the
|
|
25
|
+
* default-filled props object, not bare `undefined`.
|
|
26
|
+
* `beforeContent={() => <Header />}` — reactive accessor, MUST be called
|
|
27
|
+
* so its body's signal reads land inside the enclosing
|
|
28
|
+
* `mountReactive` effect.
|
|
29
|
+
*
|
|
30
|
+
* Both are `typeof === 'function'`. The discriminator is the marker
|
|
31
|
+
* attached by each factory:
|
|
32
|
+
* - `IS_ROCKETSTYLE` — set by `@pyreon/rocketstyle` (`rocketstyle.ts:527`,
|
|
33
|
+
* `542`) on every `rocketstyle(...).config(...)` chain end-point.
|
|
34
|
+
* - `PYREON__COMPONENT` — set by every `@pyreon/elements` component
|
|
35
|
+
* factory (Element, Text, List, Portal, Overlay, Util, Content,
|
|
36
|
+
* Wrapper, …).
|
|
37
|
+
* - `pkgName` — same components also carry this; checked as a fallback
|
|
38
|
+
* in case a third-party package mirrors the elements convention.
|
|
39
|
+
*
|
|
40
|
+
* Plain bare-function components without any marker (e.g.
|
|
41
|
+
* `const MyComp = () => <div />`) intentionally take the accessor path
|
|
42
|
+
* — they don't access props, so calling them with no args is safe AND
|
|
43
|
+
* returns the VNode the renderer expects. The marker check ONLY rescues
|
|
44
|
+
* components whose HOC pipelines REQUIRE props to be defined.
|
|
45
|
+
*
|
|
46
|
+
* Reference: regression report on 0.24.3 / PR #839 — `resolveSlot` called
|
|
47
|
+
* any function-valued slot bare, crashing real consumers (bokisch.com
|
|
48
|
+
* SSG build: `Prerendered 0 page(s) + 404.html`) that used the
|
|
49
|
+
* `beforeContent={Component}` shorthand documented since the original
|
|
50
|
+
* Element API.
|
|
51
|
+
*/
|
|
52
|
+
function isPyreonComponent(value) {
|
|
53
|
+
if (typeof value !== "function") return false;
|
|
54
|
+
return Object.hasOwn(value, "IS_ROCKETSTYLE") || Object.hasOwn(value, "PYREON__COMPONENT") || Object.hasOwn(value, "pkgName");
|
|
55
|
+
}
|
|
56
|
+
|
|
14
57
|
//#endregion
|
|
15
58
|
//#region src/helpers/Content/styled.ts
|
|
16
59
|
/**
|
|
@@ -91,8 +134,18 @@ const StyledComponent = styled$2(component$1, { layer: "elements" })`
|
|
|
91
134
|
* gap, and equalCols styling props to the underlying styled component.
|
|
92
135
|
* Adds a `data-pyr-element` attribute in development for debugging.
|
|
93
136
|
*
|
|
94
|
-
* Children are rendered via core `render()
|
|
137
|
+
* Children are rendered via core `render()`, with function-valued
|
|
138
|
+
* children unwrapped inside a reactive accessor so the compound-layout
|
|
139
|
+
* paths in `Element` keep `content={() => <X />}` reactivity intact
|
|
140
|
+
* (mirrors the `resolveSlot` helper in `Element/component.tsx`).
|
|
95
141
|
*/
|
|
142
|
+
const resolveSlot = (value) => {
|
|
143
|
+
if (typeof value === "function") {
|
|
144
|
+
if (isPyreonComponent(value)) return h(value, null);
|
|
145
|
+
return value();
|
|
146
|
+
}
|
|
147
|
+
return render(value);
|
|
148
|
+
};
|
|
96
149
|
const Component$9 = (props) => {
|
|
97
150
|
const [own, rest] = splitProps(props, [
|
|
98
151
|
"contentType",
|
|
@@ -123,7 +176,7 @@ const Component$9 = (props) => {
|
|
|
123
176
|
$element: stylingProps,
|
|
124
177
|
...debugProps,
|
|
125
178
|
...rest,
|
|
126
|
-
children:
|
|
179
|
+
children: () => resolveSlot(own.children)
|
|
127
180
|
});
|
|
128
181
|
};
|
|
129
182
|
|
|
@@ -508,6 +561,13 @@ const Component = (props) => {
|
|
|
508
561
|
const shouldBeEmpty = !!rest.dangerouslySetInnerHTML || getShouldBeEmpty(own.tag);
|
|
509
562
|
const isSimpleElement = !own.beforeContent && !own.afterContent;
|
|
510
563
|
const getChildren = () => own.children ?? own.content ?? own.label;
|
|
564
|
+
const resolveSlot = (value) => {
|
|
565
|
+
if (typeof value === "function") {
|
|
566
|
+
if (isPyreonComponent(value)) return h(value, null);
|
|
567
|
+
return value();
|
|
568
|
+
}
|
|
569
|
+
return render(value);
|
|
570
|
+
};
|
|
511
571
|
const isInline = isInlineElement(own.tag);
|
|
512
572
|
const SUB_TAG = isInline ? "span" : void 0;
|
|
513
573
|
let wrapperDirection = own.direction;
|
|
@@ -563,13 +623,13 @@ const Component = (props) => {
|
|
|
563
623
|
equalCols: own.equalCols,
|
|
564
624
|
extraStyles: own.css
|
|
565
625
|
}),
|
|
566
|
-
children:
|
|
626
|
+
children: () => resolveSlot(getChildren())
|
|
567
627
|
});
|
|
568
628
|
if (isSimpleElement) return /* @__PURE__ */ jsx(Wrapper_default, {
|
|
569
629
|
...rest,
|
|
570
630
|
...WRAPPER_PROPS,
|
|
571
631
|
isInline,
|
|
572
|
-
children:
|
|
632
|
+
children: () => resolveSlot(getChildren())
|
|
573
633
|
});
|
|
574
634
|
return /* @__PURE__ */ jsxs(Wrapper_default, {
|
|
575
635
|
...rest,
|
|
@@ -586,7 +646,7 @@ const Component = (props) => {
|
|
|
586
646
|
alignY: beforeContentAlignY,
|
|
587
647
|
equalCols: own.equalCols,
|
|
588
648
|
gap: own.gap,
|
|
589
|
-
children: own.beforeContent
|
|
649
|
+
children: () => resolveSlot(own.beforeContent)
|
|
590
650
|
}),
|
|
591
651
|
/* @__PURE__ */ jsx(Content_default, {
|
|
592
652
|
tag: SUB_TAG,
|
|
@@ -597,7 +657,7 @@ const Component = (props) => {
|
|
|
597
657
|
alignX: contentAlignX,
|
|
598
658
|
alignY: contentAlignY,
|
|
599
659
|
equalCols: own.equalCols,
|
|
600
|
-
children: getChildren()
|
|
660
|
+
children: () => resolveSlot(getChildren())
|
|
601
661
|
}),
|
|
602
662
|
own.afterContent && /* @__PURE__ */ jsx(Content_default, {
|
|
603
663
|
tag: SUB_TAG,
|
|
@@ -609,7 +669,7 @@ const Component = (props) => {
|
|
|
609
669
|
alignY: afterContentAlignY,
|
|
610
670
|
equalCols: own.equalCols,
|
|
611
671
|
gap: own.gap,
|
|
612
|
-
children: own.afterContent
|
|
672
|
+
children: () => resolveSlot(own.afterContent)
|
|
613
673
|
})
|
|
614
674
|
]
|
|
615
675
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/elements",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.4",
|
|
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.
|
|
45
|
+
"@pyreon/core": "^0.24.4",
|
|
46
46
|
"@pyreon/manifest": "0.13.1",
|
|
47
|
-
"@pyreon/reactivity": "^0.24.
|
|
48
|
-
"@pyreon/runtime-dom": "^0.24.
|
|
47
|
+
"@pyreon/reactivity": "^0.24.4",
|
|
48
|
+
"@pyreon/runtime-dom": "^0.24.4",
|
|
49
49
|
"@pyreon/test-utils": "^0.13.11",
|
|
50
|
-
"@pyreon/typescript": "^0.24.
|
|
50
|
+
"@pyreon/typescript": "^0.24.4",
|
|
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.
|
|
59
|
-
"@pyreon/reactivity": "^0.24.
|
|
60
|
-
"@pyreon/ui-core": "^0.24.
|
|
61
|
-
"@pyreon/unistyle": "^0.24.
|
|
58
|
+
"@pyreon/core": "^0.24.4",
|
|
59
|
+
"@pyreon/reactivity": "^0.24.4",
|
|
60
|
+
"@pyreon/ui-core": "^0.24.4",
|
|
61
|
+
"@pyreon/unistyle": "^0.24.4"
|
|
62
62
|
}
|
|
63
63
|
}
|
|
@@ -7,11 +7,13 @@
|
|
|
7
7
|
* skipping children or switching sub-tags accordingly.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { onMount, splitProps } from '@pyreon/core'
|
|
10
|
+
import { h, onMount, splitProps } from '@pyreon/core'
|
|
11
|
+
import type { ComponentFn, 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'
|
|
14
15
|
import { internElementBundle } from '../helpers/internElementBundle'
|
|
16
|
+
import { isPyreonComponent } from '../helpers/isPyreonComponent'
|
|
15
17
|
import WrapperStyled from '../helpers/Wrapper/styled'
|
|
16
18
|
import { isWebFixNeeded } from '../helpers/Wrapper/utils'
|
|
17
19
|
import { IS_DEVELOPMENT } from '../utils'
|
|
@@ -102,6 +104,46 @@ const Component: PyreonElement = (props) => {
|
|
|
102
104
|
// Reading own.content via ?? at setup would capture the value once.
|
|
103
105
|
const getChildren = () => own.children ?? own.content ?? own.label
|
|
104
106
|
|
|
107
|
+
// Resolve a slot value INSIDE a reactive accessor. If the consumer passed a
|
|
108
|
+
// function-returning-VNode (e.g. `content={() => <Icon name={signal()} />}`),
|
|
109
|
+
// unwrap it by calling — its body's signal reads are then tracked by the
|
|
110
|
+
// enclosing mountReactive effect, and the slot re-renders on signal change.
|
|
111
|
+
// Static VNodes / strings / null pass through unchanged to `render()`.
|
|
112
|
+
//
|
|
113
|
+
// **Component vs accessor discriminator** (regression fix for the
|
|
114
|
+
// 0.24.3 → 0.24.4 follow-up — see #839 for the original reactive-slot fix):
|
|
115
|
+
// `beforeContent={Header}` (component-reference shorthand) and
|
|
116
|
+
// `content={() => <X />}` (reactive accessor) are BOTH `typeof === 'function'`.
|
|
117
|
+
// PR #839 called both bare, which crashed component shorthands the moment a
|
|
118
|
+
// rocketstyle / attrs HOC ran `removeUndefinedProps(undefined)` on the
|
|
119
|
+
// un-supplied props (`TypeError: Cannot convert undefined or null to object`).
|
|
120
|
+
//
|
|
121
|
+
// Discriminator: framework components carry one of two markers attached by
|
|
122
|
+
// their factory:
|
|
123
|
+
// - `IS_ROCKETSTYLE` — anything `rocketstyle()` produces
|
|
124
|
+
// - `PYREON__COMPONENT` / `pkgName` — `@pyreon/elements` components
|
|
125
|
+
// (Element, Text, List, Portal, Overlay, Util)
|
|
126
|
+
// Marked function → mount as `h(Component, null)` (no props, defaults
|
|
127
|
+
// fill in via the HOC pipeline). Unmarked function → reactive accessor,
|
|
128
|
+
// called bare so its return value (a VNode) renders. Bare-function
|
|
129
|
+
// components without HOC wrapping (e.g. `const MyComp = () => <div />`)
|
|
130
|
+
// also work via the accessor path — they're called with no args and
|
|
131
|
+
// their VNode return goes through `render()` correctly. The marker
|
|
132
|
+
// check ONLY rescues components that REQUIRE props to be defined.
|
|
133
|
+
//
|
|
134
|
+
// Return type is the RESOLVED atom (VNodeChildAtom | VNodeChildAtom[]) —
|
|
135
|
+
// never a nested accessor — so the enclosing `() => resolveSlot(...)` IS
|
|
136
|
+
// a valid VNodeChildAccessor in the JSX child position.
|
|
137
|
+
const resolveSlot = (value: unknown): VNodeChildAtom | VNodeChildAtom[] => {
|
|
138
|
+
if (typeof value === 'function') {
|
|
139
|
+
if (isPyreonComponent(value)) {
|
|
140
|
+
return h(value as ComponentFn, null) as VNodeChildAtom
|
|
141
|
+
}
|
|
142
|
+
return (value as () => VNodeChildAtom | VNodeChildAtom[])()
|
|
143
|
+
}
|
|
144
|
+
return render(value as Parameters<typeof render>[0]) as VNodeChildAtom | VNodeChildAtom[]
|
|
145
|
+
}
|
|
146
|
+
|
|
105
147
|
const isInline = isInlineElement(own.tag)
|
|
106
148
|
const SUB_TAG = isInline ? 'span' : undefined
|
|
107
149
|
|
|
@@ -201,7 +243,7 @@ const Component: PyreonElement = (props) => {
|
|
|
201
243
|
extraStyles: own.css,
|
|
202
244
|
})}
|
|
203
245
|
>
|
|
204
|
-
{
|
|
246
|
+
{() => resolveSlot(getChildren())}
|
|
205
247
|
</WrapperStyled>
|
|
206
248
|
)
|
|
207
249
|
}
|
|
@@ -209,7 +251,7 @@ const Component: PyreonElement = (props) => {
|
|
|
209
251
|
if (isSimpleElement) {
|
|
210
252
|
return (
|
|
211
253
|
<Wrapper {...rest} {...WRAPPER_PROPS} isInline={isInline}>
|
|
212
|
-
{
|
|
254
|
+
{() => resolveSlot(getChildren())}
|
|
213
255
|
</Wrapper>
|
|
214
256
|
)
|
|
215
257
|
}
|
|
@@ -228,7 +270,7 @@ const Component: PyreonElement = (props) => {
|
|
|
228
270
|
equalCols={own.equalCols}
|
|
229
271
|
gap={own.gap}
|
|
230
272
|
>
|
|
231
|
-
{own.beforeContent}
|
|
273
|
+
{() => resolveSlot(own.beforeContent)}
|
|
232
274
|
</Content>
|
|
233
275
|
)}
|
|
234
276
|
|
|
@@ -242,7 +284,7 @@ const Component: PyreonElement = (props) => {
|
|
|
242
284
|
alignY={contentAlignY}
|
|
243
285
|
equalCols={own.equalCols}
|
|
244
286
|
>
|
|
245
|
-
{getChildren()}
|
|
287
|
+
{() => resolveSlot(getChildren())}
|
|
246
288
|
</Content>
|
|
247
289
|
|
|
248
290
|
{own.afterContent && (
|
|
@@ -257,7 +299,7 @@ const Component: PyreonElement = (props) => {
|
|
|
257
299
|
equalCols={own.equalCols}
|
|
258
300
|
gap={own.gap}
|
|
259
301
|
>
|
|
260
|
-
{own.afterContent}
|
|
302
|
+
{() => resolveSlot(own.afterContent)}
|
|
261
303
|
</Content>
|
|
262
304
|
)}
|
|
263
305
|
</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
|
-
|
|
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
|
-
|
|
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', () => {
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Regression: 0.24.3 (PR #839) added `resolveSlot` to make function-valued
|
|
2
|
+
// slot props reactive — `content={() => <X name={signal()} />}`. The
|
|
3
|
+
// implementation calls ANY function-typed slot value with no args, which
|
|
4
|
+
// crashes when the consumer passes a COMPONENT reference using the
|
|
5
|
+
// shorthand `beforeContent={Header}` (Header is `typeof === 'function'`
|
|
6
|
+
// too — `resolveSlot` calls it with no props, downstream
|
|
7
|
+
// `removeUndefinedProps(undefined)` throws
|
|
8
|
+
// `TypeError: Cannot convert undefined or null to object`).
|
|
9
|
+
//
|
|
10
|
+
// Reported by a real consumer (bokisch.com 0.24.3 → SSG build fails on
|
|
11
|
+
// every route that uses `beforeContent={Component}` shorthand —
|
|
12
|
+
// `Prerendered 0 page(s) + 404.html`).
|
|
13
|
+
//
|
|
14
|
+
// The discriminator: framework component functions carry a marker
|
|
15
|
+
// (`IS_ROCKETSTYLE` for rocketstyle wrappers, `PYREON__COMPONENT` for
|
|
16
|
+
// `@pyreon/elements` components). `resolveSlot` must mount marked
|
|
17
|
+
// components via `h(Component, null)` instead of calling bare.
|
|
18
|
+
import type { VNode, VNodeChild } from '@pyreon/core'
|
|
19
|
+
import { h } from '@pyreon/core'
|
|
20
|
+
import { describe, expect, it } from 'vitest'
|
|
21
|
+
import Element from '../Element/component'
|
|
22
|
+
|
|
23
|
+
// Match the bokisch.com bug shape: a rocketstyle-marked component used
|
|
24
|
+
// as a slot reference (NOT wrapped in `() => <Logo />`). The body
|
|
25
|
+
// requires non-undefined props — calling it with no args throws,
|
|
26
|
+
// exactly mirroring the real `removeUndefinedProps(undefined)` crash.
|
|
27
|
+
function makeRocketstyleStub(name: string, content: string) {
|
|
28
|
+
const Component: any = (props: { className?: string } | undefined) => {
|
|
29
|
+
Object.getOwnPropertyDescriptors(props as object)
|
|
30
|
+
return h('div', { 'data-component': name, class: props?.className }, content)
|
|
31
|
+
}
|
|
32
|
+
Component.IS_ROCKETSTYLE = true
|
|
33
|
+
Component.displayName = name
|
|
34
|
+
return Component
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Match the @pyreon/elements framework-component shape.
|
|
38
|
+
function makeElementStub(name: string, content: string) {
|
|
39
|
+
const Component: any = (props: { className?: string } | undefined) => {
|
|
40
|
+
Object.getOwnPropertyDescriptors(props as object)
|
|
41
|
+
return h('div', { 'data-component': name, class: props?.className }, content)
|
|
42
|
+
}
|
|
43
|
+
Component.PYREON__COMPONENT = `@pyreon/elements/${name}`
|
|
44
|
+
Component.pkgName = '@pyreon/elements'
|
|
45
|
+
return Component
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Walk Element's VNode tree to find ALL accessor-function children and
|
|
50
|
+
* invoke them. Element wraps slot rendering as `{() => resolveSlot(value)}`
|
|
51
|
+
* in the JSX child position — those closures are what mount the slot, and
|
|
52
|
+
* are what crashes in the broken state when `value` is a component
|
|
53
|
+
* reference. Calling the closures mirrors how runtime-dom and
|
|
54
|
+
* runtime-server invoke them during render.
|
|
55
|
+
*
|
|
56
|
+
* Returns the array of accessor results in tree order.
|
|
57
|
+
*/
|
|
58
|
+
function invokeAllSlotAccessors(root: VNode): unknown[] {
|
|
59
|
+
const results: unknown[] = []
|
|
60
|
+
const visit = (node: VNodeChild | unknown): void => {
|
|
61
|
+
if (typeof node === 'function') {
|
|
62
|
+
// Reactive-accessor child position
|
|
63
|
+
results.push((node as () => unknown)())
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
if (!node || typeof node !== 'object' || Array.isArray(node)) {
|
|
67
|
+
if (Array.isArray(node)) node.forEach(visit)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
const vnode = node as VNode & { props?: { children?: unknown } }
|
|
71
|
+
// Pyreon `h()` stores JSX children in BOTH `vnode.children` (array)
|
|
72
|
+
// AND `props.children` (single value). The slot-accessor closure
|
|
73
|
+
// typically lands in `props.children` when passed explicitly.
|
|
74
|
+
if (vnode.props && 'children' in vnode.props) {
|
|
75
|
+
visit(vnode.props.children)
|
|
76
|
+
}
|
|
77
|
+
if (Array.isArray(vnode.children)) vnode.children.forEach(visit)
|
|
78
|
+
else if (vnode.children) visit(vnode.children as VNodeChild)
|
|
79
|
+
}
|
|
80
|
+
visit(root)
|
|
81
|
+
return results
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe('Element slot — component-reference shorthand (regression #839 follow-up)', () => {
|
|
85
|
+
it('beforeContent={RocketstyleComponent} mounts via h(Component) — does NOT crash', () => {
|
|
86
|
+
const Logo = makeRocketstyleStub('Logo', 'logo')
|
|
87
|
+
const result = Element({
|
|
88
|
+
tag: 'header',
|
|
89
|
+
beforeContent: Logo,
|
|
90
|
+
content: 'title',
|
|
91
|
+
}) as VNode
|
|
92
|
+
expect(() => invokeAllSlotAccessors(result)).not.toThrow()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('afterContent={RocketstyleComponent} does not crash', () => {
|
|
96
|
+
const Badge = makeRocketstyleStub('Badge', 'NEW')
|
|
97
|
+
const result = Element({
|
|
98
|
+
tag: 'header',
|
|
99
|
+
content: 'title',
|
|
100
|
+
afterContent: Badge,
|
|
101
|
+
}) as VNode
|
|
102
|
+
expect(() => invokeAllSlotAccessors(result)).not.toThrow()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('content={RocketstyleComponent} (simple-element fast path) yields h(Component) VNode', () => {
|
|
106
|
+
const Header = makeRocketstyleStub('Header', 'page header')
|
|
107
|
+
const result = Element({ tag: 'header', content: Header }) as VNode
|
|
108
|
+
const results = invokeAllSlotAccessors(result)
|
|
109
|
+
expect(results.length).toBeGreaterThan(0)
|
|
110
|
+
// First accessor result is the slot content. It must be the VNode
|
|
111
|
+
// `h(Header, null)` — NOT the result of calling Header bare (which
|
|
112
|
+
// would crash in the broken state, OR succeed in pre-PR-839 state
|
|
113
|
+
// by accident if Header doesn't access props).
|
|
114
|
+
const first = results[0] as VNode
|
|
115
|
+
expect(first.type).toBe(Header)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('content={ElementComponent} (PYREON__COMPONENT marker) yields h(Component) VNode', () => {
|
|
119
|
+
const Inner = makeElementStub('Inner', 'inner')
|
|
120
|
+
const result = Element({ tag: 'section', content: Inner }) as VNode
|
|
121
|
+
const results = invokeAllSlotAccessors(result)
|
|
122
|
+
expect(results.length).toBeGreaterThan(0)
|
|
123
|
+
const first = results[0] as VNode
|
|
124
|
+
expect(first.type).toBe(Inner)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
// Counter-cases — the discriminator must NOT break the reactive-accessor
|
|
128
|
+
// shape PR #839 fixed.
|
|
129
|
+
it('content={() => <X />} (plain accessor) still calls function bare — reactive intact', () => {
|
|
130
|
+
let called = 0
|
|
131
|
+
const accessor = () => {
|
|
132
|
+
called++
|
|
133
|
+
return h('div', { 'data-accessor': 'called' }, 'accessor-output')
|
|
134
|
+
}
|
|
135
|
+
const result = Element({ tag: 'div', content: accessor }) as VNode
|
|
136
|
+
const results = invokeAllSlotAccessors(result)
|
|
137
|
+
expect(called).toBeGreaterThan(0)
|
|
138
|
+
// Returns the VNode the accessor produced (NOT an `h(accessor, null)` wrap).
|
|
139
|
+
const first = results[0] as VNode
|
|
140
|
+
expect(first.type).toBe('div')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('beforeContent={() => h(Component)} (accessor returning a VNode) still works', () => {
|
|
144
|
+
const Logo = makeRocketstyleStub('Logo', 'logo')
|
|
145
|
+
let called = 0
|
|
146
|
+
const result = Element({
|
|
147
|
+
tag: 'header',
|
|
148
|
+
beforeContent: () => {
|
|
149
|
+
called++
|
|
150
|
+
return h(Logo, null)
|
|
151
|
+
},
|
|
152
|
+
content: 'title',
|
|
153
|
+
}) as VNode
|
|
154
|
+
expect(() => invokeAllSlotAccessors(result)).not.toThrow()
|
|
155
|
+
expect(called).toBeGreaterThan(0)
|
|
156
|
+
})
|
|
157
|
+
})
|
|
@@ -4,14 +4,36 @@
|
|
|
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
|
-
import { splitProps } from '@pyreon/core'
|
|
12
|
+
import { h, splitProps } from '@pyreon/core'
|
|
13
|
+
import type { ComponentFn, VNodeChildAtom } from '@pyreon/core'
|
|
10
14
|
import { render } from '@pyreon/ui-core'
|
|
11
15
|
import { IS_DEVELOPMENT } from '../../utils'
|
|
16
|
+
import { isPyreonComponent } from '../isPyreonComponent'
|
|
12
17
|
import Styled from './styled'
|
|
13
18
|
import type { Props } from './types'
|
|
14
19
|
|
|
20
|
+
// Return type is the RESOLVED atom — see the matching helper in
|
|
21
|
+
// Element/component.tsx for the rationale (keeps `() => resolveSlot(...)`
|
|
22
|
+
// a valid VNodeChildAccessor at the JSX child position).
|
|
23
|
+
//
|
|
24
|
+
// Component vs accessor discriminator — see `isPyreonComponent` JSDoc.
|
|
25
|
+
// Without this, `beforeContent={Component}` shorthand crashes downstream
|
|
26
|
+
// in rocketstyle's `removeUndefinedProps(undefined)`.
|
|
27
|
+
const resolveSlot = (value: unknown): VNodeChildAtom | VNodeChildAtom[] => {
|
|
28
|
+
if (typeof value === 'function') {
|
|
29
|
+
if (isPyreonComponent(value)) {
|
|
30
|
+
return h(value as ComponentFn, null) as VNodeChildAtom
|
|
31
|
+
}
|
|
32
|
+
return (value as () => VNodeChildAtom | VNodeChildAtom[])()
|
|
33
|
+
}
|
|
34
|
+
return render(value as Parameters<typeof render>[0]) as VNodeChildAtom | VNodeChildAtom[]
|
|
35
|
+
}
|
|
36
|
+
|
|
15
37
|
const Component = (props: Partial<Props>) => {
|
|
16
38
|
const [own, rest] = splitProps(props, [
|
|
17
39
|
'contentType',
|
|
@@ -45,7 +67,7 @@ const Component = (props: Partial<Props>) => {
|
|
|
45
67
|
|
|
46
68
|
return (
|
|
47
69
|
<Styled as={own.tag} $contentType={own.contentType} $element={stylingProps} {...debugProps} {...rest}>
|
|
48
|
-
{
|
|
70
|
+
{() => resolveSlot(own.children)}
|
|
49
71
|
</Styled>
|
|
50
72
|
)
|
|
51
73
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect whether a function value is a framework component (created via
|
|
3
|
+
* `rocketstyle()` or one of `@pyreon/elements`' component factories), as
|
|
4
|
+
* opposed to a plain reactive-accessor function.
|
|
5
|
+
*
|
|
6
|
+
* Used by `Element` / `Content` `resolveSlot` to discriminate between
|
|
7
|
+
* `beforeContent={Header}` — component-reference shorthand, MUST mount
|
|
8
|
+
* as `h(Component, null)` so the framework HOC's
|
|
9
|
+
* `removeUndefinedProps(props)` / `splitProps(props)` get the
|
|
10
|
+
* default-filled props object, not bare `undefined`.
|
|
11
|
+
* `beforeContent={() => <Header />}` — reactive accessor, MUST be called
|
|
12
|
+
* so its body's signal reads land inside the enclosing
|
|
13
|
+
* `mountReactive` effect.
|
|
14
|
+
*
|
|
15
|
+
* Both are `typeof === 'function'`. The discriminator is the marker
|
|
16
|
+
* attached by each factory:
|
|
17
|
+
* - `IS_ROCKETSTYLE` — set by `@pyreon/rocketstyle` (`rocketstyle.ts:527`,
|
|
18
|
+
* `542`) on every `rocketstyle(...).config(...)` chain end-point.
|
|
19
|
+
* - `PYREON__COMPONENT` — set by every `@pyreon/elements` component
|
|
20
|
+
* factory (Element, Text, List, Portal, Overlay, Util, Content,
|
|
21
|
+
* Wrapper, …).
|
|
22
|
+
* - `pkgName` — same components also carry this; checked as a fallback
|
|
23
|
+
* in case a third-party package mirrors the elements convention.
|
|
24
|
+
*
|
|
25
|
+
* Plain bare-function components without any marker (e.g.
|
|
26
|
+
* `const MyComp = () => <div />`) intentionally take the accessor path
|
|
27
|
+
* — they don't access props, so calling them with no args is safe AND
|
|
28
|
+
* returns the VNode the renderer expects. The marker check ONLY rescues
|
|
29
|
+
* components whose HOC pipelines REQUIRE props to be defined.
|
|
30
|
+
*
|
|
31
|
+
* Reference: regression report on 0.24.3 / PR #839 — `resolveSlot` called
|
|
32
|
+
* any function-valued slot bare, crashing real consumers (bokisch.com
|
|
33
|
+
* SSG build: `Prerendered 0 page(s) + 404.html`) that used the
|
|
34
|
+
* `beforeContent={Component}` shorthand documented since the original
|
|
35
|
+
* Element API.
|
|
36
|
+
*/
|
|
37
|
+
export function isPyreonComponent(value: unknown): boolean {
|
|
38
|
+
if (typeof value !== 'function') return false
|
|
39
|
+
// `Object.hasOwn` (not `in`) so a marker on a parent prototype doesn't
|
|
40
|
+
// count — the marker is always an own-property in the factories.
|
|
41
|
+
return (
|
|
42
|
+
Object.hasOwn(value, 'IS_ROCKETSTYLE') ||
|
|
43
|
+
Object.hasOwn(value, 'PYREON__COMPONENT') ||
|
|
44
|
+
Object.hasOwn(value, 'pkgName')
|
|
45
|
+
)
|
|
46
|
+
}
|