@pyreon/elements 0.24.3 → 0.24.5

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
@@ -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
  /**
@@ -97,7 +140,10 @@ const StyledComponent = styled$2(component$1, { layer: "elements" })`
97
140
  * (mirrors the `resolveSlot` helper in `Element/component.tsx`).
98
141
  */
99
142
  const resolveSlot = (value) => {
100
- if (typeof value === "function") return value();
143
+ if (typeof value === "function") {
144
+ if (isPyreonComponent(value)) return h(value, null);
145
+ return value();
146
+ }
101
147
  return render(value);
102
148
  };
103
149
  const Component$9 = (props) => {
@@ -516,7 +562,10 @@ const Component = (props) => {
516
562
  const isSimpleElement = !own.beforeContent && !own.afterContent;
517
563
  const getChildren = () => own.children ?? own.content ?? own.label;
518
564
  const resolveSlot = (value) => {
519
- if (typeof value === "function") return value();
565
+ if (typeof value === "function") {
566
+ if (isPyreonComponent(value)) return h(value, null);
567
+ return value();
568
+ }
520
569
  return render(value);
521
570
  };
522
571
  const isInline = isInlineElement(own.tag);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/elements",
3
- "version": "0.24.3",
3
+ "version": "0.24.5",
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.3",
45
+ "@pyreon/core": "^0.24.5",
46
46
  "@pyreon/manifest": "0.13.1",
47
- "@pyreon/reactivity": "^0.24.3",
48
- "@pyreon/runtime-dom": "^0.24.3",
47
+ "@pyreon/reactivity": "^0.24.5",
48
+ "@pyreon/runtime-dom": "^0.24.5",
49
49
  "@pyreon/test-utils": "^0.13.11",
50
- "@pyreon/typescript": "^0.24.3",
50
+ "@pyreon/typescript": "^0.24.5",
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.3",
59
- "@pyreon/reactivity": "^0.24.3",
60
- "@pyreon/ui-core": "^0.24.3",
61
- "@pyreon/unistyle": "^0.24.3"
58
+ "@pyreon/core": "^0.24.5",
59
+ "@pyreon/reactivity": "^0.24.5",
60
+ "@pyreon/ui-core": "^0.24.5",
61
+ "@pyreon/unistyle": "^0.24.5"
62
62
  }
63
63
  }
@@ -7,12 +7,13 @@
7
7
  * skipping children or switching sub-tags accordingly.
8
8
  */
9
9
 
10
- import { onMount, splitProps } from '@pyreon/core'
11
- import type { VNodeChildAtom } from '@pyreon/core'
10
+ import { h, onMount, splitProps } from '@pyreon/core'
11
+ import type { ComponentFn, VNodeChildAtom } from '@pyreon/core'
12
12
  import { render } from '@pyreon/ui-core'
13
13
  import { PKG_NAME } from '../constants'
14
14
  import { Content, Wrapper } from '../helpers'
15
15
  import { internElementBundle } from '../helpers/internElementBundle'
16
+ import { isPyreonComponent } from '../helpers/isPyreonComponent'
16
17
  import WrapperStyled from '../helpers/Wrapper/styled'
17
18
  import { isWebFixNeeded } from '../helpers/Wrapper/utils'
18
19
  import { IS_DEVELOPMENT } from '../utils'
@@ -109,18 +110,35 @@ const Component: PyreonElement = (props) => {
109
110
  // enclosing mountReactive effect, and the slot re-renders on signal change.
110
111
  // Static VNodes / strings / null pass through unchanged to `render()`.
111
112
  //
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).
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
+ //
119
134
  // Return type is the RESOLVED atom (VNodeChildAtom | VNodeChildAtom[]) —
120
135
  // never a nested accessor — so the enclosing `() => resolveSlot(...)` IS
121
136
  // a valid VNodeChildAccessor in the JSX child position.
122
137
  const resolveSlot = (value: unknown): VNodeChildAtom | VNodeChildAtom[] => {
123
138
  if (typeof value === 'function') {
139
+ if (isPyreonComponent(value)) {
140
+ return h(value as ComponentFn, null) as VNodeChildAtom
141
+ }
124
142
  return (value as () => VNodeChildAtom | VNodeChildAtom[])()
125
143
  }
126
144
  return render(value as Parameters<typeof render>[0]) as VNodeChildAtom | VNodeChildAtom[]
@@ -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
+ })
@@ -9,18 +9,26 @@
9
9
  * paths in `Element` keep `content={() => <X />}` reactivity intact
10
10
  * (mirrors the `resolveSlot` helper in `Element/component.tsx`).
11
11
  */
12
- import { splitProps } from '@pyreon/core'
13
- import type { VNodeChildAtom } from '@pyreon/core'
12
+ import { h, splitProps } from '@pyreon/core'
13
+ import type { ComponentFn, VNodeChildAtom } from '@pyreon/core'
14
14
  import { render } from '@pyreon/ui-core'
15
15
  import { IS_DEVELOPMENT } from '../../utils'
16
+ import { isPyreonComponent } from '../isPyreonComponent'
16
17
  import Styled from './styled'
17
18
  import type { Props } from './types'
18
19
 
19
20
  // Return type is the RESOLVED atom — see the matching helper in
20
21
  // Element/component.tsx for the rationale (keeps `() => resolveSlot(...)`
21
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)`.
22
27
  const resolveSlot = (value: unknown): VNodeChildAtom | VNodeChildAtom[] => {
23
28
  if (typeof value === 'function') {
29
+ if (isPyreonComponent(value)) {
30
+ return h(value as ComponentFn, null) as VNodeChildAtom
31
+ }
24
32
  return (value as () => VNodeChildAtom | VNodeChildAtom[])()
25
33
  }
26
34
  return render(value as Parameters<typeof render>[0]) as VNodeChildAtom | VNodeChildAtom[]
@@ -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
+ }