@pyreon/elements 0.13.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +24 -1
- package/lib/index.js.map +1 -1
- package/package.json +10 -10
- package/src/Element/component.tsx +58 -16
- package/src/__tests__/Element.test.ts +95 -46
- package/src/__tests__/equalBeforeAfter.test.ts +4 -4
- package/src/__tests__/integration.test.tsx +36 -5
- package/src/__tests__/perf-stress.browser.test.tsx +119 -0
- package/src/__tests__/responsiveProps.test.ts +59 -28
|
@@ -11,9 +11,16 @@ import { onMount, splitProps } from '@pyreon/core'
|
|
|
11
11
|
import { render } from '@pyreon/ui-core'
|
|
12
12
|
import { PKG_NAME } from '../constants'
|
|
13
13
|
import { Content, Wrapper } from '../helpers'
|
|
14
|
+
import WrapperStyled from '../helpers/Wrapper/styled'
|
|
15
|
+
import { isWebFixNeeded } from '../helpers/Wrapper/utils'
|
|
16
|
+
import { IS_DEVELOPMENT } from '../utils'
|
|
14
17
|
import type { PyreonElement } from './types'
|
|
15
18
|
import { getShouldBeEmpty, isInlineElement } from './utils'
|
|
16
19
|
|
|
20
|
+
const WRAPPER_DEV_PROPS: Record<string, string> = IS_DEVELOPMENT
|
|
21
|
+
? { 'data-pyr-element': 'Element' }
|
|
22
|
+
: {}
|
|
23
|
+
|
|
17
24
|
const equalize = (el: HTMLElement, direction: unknown) => {
|
|
18
25
|
const beforeEl = el.firstElementChild as HTMLElement | null
|
|
19
26
|
const afterEl = el.lastElementChild as HTMLElement | null
|
|
@@ -156,6 +163,45 @@ const Component: PyreonElement = (props) => {
|
|
|
156
163
|
return <Wrapper {...rest} {...WRAPPER_PROPS} />
|
|
157
164
|
}
|
|
158
165
|
|
|
166
|
+
// Simple-Element fast path: no beforeContent / afterContent slots, and no
|
|
167
|
+
// button/fieldset/legend two-layer flex fix needed. Inline the Wrapper
|
|
168
|
+
// helper directly into a single Styled invocation — saves one component
|
|
169
|
+
// hop, one splitProps call, and one mountChild per Element. The Wrapper
|
|
170
|
+
// helper still exists for the rare needsFix case below; tests that asserted
|
|
171
|
+
// Wrapper appears in the VNode tree are updated to the new shape.
|
|
172
|
+
const dangerouslySetInnerHTML = (rest as { dangerouslySetInnerHTML?: unknown })
|
|
173
|
+
.dangerouslySetInnerHTML
|
|
174
|
+
const needsFix = !dangerouslySetInnerHTML && isWebFixNeeded(own.tag)
|
|
175
|
+
|
|
176
|
+
if (isSimpleElement && !needsFix) {
|
|
177
|
+
return (
|
|
178
|
+
<WrapperStyled
|
|
179
|
+
{...rest}
|
|
180
|
+
{...WRAPPER_DEV_PROPS}
|
|
181
|
+
ref={mergedRef}
|
|
182
|
+
as={own.tag}
|
|
183
|
+
$element={{
|
|
184
|
+
block: own.block,
|
|
185
|
+
direction: wrapperDirection,
|
|
186
|
+
alignX: wrapperAlignX,
|
|
187
|
+
alignY: wrapperAlignY,
|
|
188
|
+
equalCols: own.equalCols,
|
|
189
|
+
extraStyles: own.css,
|
|
190
|
+
}}
|
|
191
|
+
>
|
|
192
|
+
{render(getChildren())}
|
|
193
|
+
</WrapperStyled>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (isSimpleElement) {
|
|
198
|
+
return (
|
|
199
|
+
<Wrapper {...rest} {...WRAPPER_PROPS} isInline={isInline}>
|
|
200
|
+
{render(getChildren())}
|
|
201
|
+
</Wrapper>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
159
205
|
return (
|
|
160
206
|
<Wrapper {...rest} {...WRAPPER_PROPS} isInline={isInline}>
|
|
161
207
|
{own.beforeContent && (
|
|
@@ -174,22 +220,18 @@ const Component: PyreonElement = (props) => {
|
|
|
174
220
|
</Content>
|
|
175
221
|
)}
|
|
176
222
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
>
|
|
190
|
-
{getChildren()}
|
|
191
|
-
</Content>
|
|
192
|
-
)}
|
|
223
|
+
<Content
|
|
224
|
+
tag={SUB_TAG}
|
|
225
|
+
contentType="content"
|
|
226
|
+
parentDirection={wrapperDirection}
|
|
227
|
+
extendCss={own.contentCss}
|
|
228
|
+
direction={contentDirection}
|
|
229
|
+
alignX={contentAlignX}
|
|
230
|
+
alignY={contentAlignY}
|
|
231
|
+
equalCols={own.equalCols}
|
|
232
|
+
>
|
|
233
|
+
{getChildren()}
|
|
234
|
+
</Content>
|
|
193
235
|
|
|
194
236
|
{own.afterContent && (
|
|
195
237
|
<Content
|
|
@@ -20,59 +20,102 @@ const getContentSlots = (result: VNode): VNode[] => {
|
|
|
20
20
|
) as VNode[]
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Element's simple-Element fast path inlines the Wrapper helper directly into
|
|
25
|
+
* a single Styled invocation — saves a component hop, splitProps call, and
|
|
26
|
+
* mountChild per Element. After the inline, layout props live on
|
|
27
|
+
* `props.$element.{direction,alignX,…}` and the HTML tag moves from `tag` to
|
|
28
|
+
* `as`. Compound (with-beforeContent/afterContent) renders and the rare
|
|
29
|
+
* needsFix tags (button/fieldset/legend) still go through the original
|
|
30
|
+
* `Wrapper` component.
|
|
31
|
+
*
|
|
32
|
+
* `getLayoutProps()` reads from whichever shape the result happens to be in
|
|
33
|
+
* so the test assertions don't need to know which path Element took.
|
|
34
|
+
*/
|
|
35
|
+
const getLayoutProps = (result: VNode): Record<string, unknown> => {
|
|
36
|
+
const p = result.props as Record<string, unknown>
|
|
37
|
+
// Inlined path: layout lives in $element bag, tag in `as`
|
|
38
|
+
if (p.$element && typeof p.$element === 'object') {
|
|
39
|
+
const el = p.$element as Record<string, unknown>
|
|
40
|
+
return {
|
|
41
|
+
tag: p.as,
|
|
42
|
+
direction: el.direction,
|
|
43
|
+
alignX: el.alignX,
|
|
44
|
+
alignY: el.alignY,
|
|
45
|
+
block: el.block,
|
|
46
|
+
equalCols: el.equalCols,
|
|
47
|
+
extendCss: el.extraStyles,
|
|
48
|
+
// isInline is no longer a separate prop in the inlined path —
|
|
49
|
+
// SUB_TAG/childFix decisions happen at compose-time.
|
|
50
|
+
isInline: undefined,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Wrapper-helper path: flat props
|
|
54
|
+
return {
|
|
55
|
+
tag: p.tag,
|
|
56
|
+
direction: p.direction,
|
|
57
|
+
alignX: p.alignX,
|
|
58
|
+
alignY: p.alignY,
|
|
59
|
+
block: p.block,
|
|
60
|
+
equalCols: p.equalCols,
|
|
61
|
+
extendCss: p.extendCss,
|
|
62
|
+
isInline: p.isInline,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
23
66
|
describe('Element', () => {
|
|
24
67
|
describe('basic rendering', () => {
|
|
25
68
|
it('returns a VNode whose type is the Wrapper component (a function)', () => {
|
|
26
69
|
const result = asVNode(Element({ children: 'hello' }))
|
|
27
70
|
expect(typeof result.type).toBe('function')
|
|
28
|
-
expect(result.type).toBe(
|
|
71
|
+
expect(typeof result.type).toBe("function")
|
|
29
72
|
})
|
|
30
73
|
|
|
31
74
|
it('passes tag as the tag prop to Wrapper', () => {
|
|
32
75
|
const result = asVNode(Element({ tag: 'section', children: 'content' }))
|
|
33
|
-
expect(result.
|
|
76
|
+
expect(getLayoutProps(result).tag).toBe('section')
|
|
34
77
|
})
|
|
35
78
|
|
|
36
79
|
it('defaults tag to undefined when not specified', () => {
|
|
37
80
|
const result = asVNode(Element({ children: 'hello' }))
|
|
38
|
-
expect(result.
|
|
81
|
+
expect(getLayoutProps(result).tag).toBeUndefined()
|
|
39
82
|
})
|
|
40
83
|
|
|
41
84
|
it('renders with no children', () => {
|
|
42
85
|
const result = asVNode(Element({}))
|
|
43
|
-
expect(result.type).toBe(
|
|
86
|
+
expect(typeof result.type).toBe("function")
|
|
44
87
|
})
|
|
45
88
|
})
|
|
46
89
|
|
|
47
90
|
describe('simple element (no beforeContent/afterContent)', () => {
|
|
48
91
|
it('uses contentDirection as wrapper direction (defaults to rows)', () => {
|
|
49
92
|
const result = asVNode(Element({ children: 'test' }))
|
|
50
|
-
expect(result.
|
|
93
|
+
expect(getLayoutProps(result).direction).toBe('rows')
|
|
51
94
|
})
|
|
52
95
|
|
|
53
96
|
it('uses contentAlignX as wrapper alignX (defaults to left)', () => {
|
|
54
97
|
const result = asVNode(Element({ children: 'test' }))
|
|
55
|
-
expect(result.
|
|
98
|
+
expect(getLayoutProps(result).alignX).toBe('left')
|
|
56
99
|
})
|
|
57
100
|
|
|
58
101
|
it('uses contentAlignY as wrapper alignY (defaults to center)', () => {
|
|
59
102
|
const result = asVNode(Element({ children: 'test' }))
|
|
60
|
-
expect(result.
|
|
103
|
+
expect(getLayoutProps(result).alignY).toBe('center')
|
|
61
104
|
})
|
|
62
105
|
|
|
63
106
|
it('overrides direction with contentDirection when simple', () => {
|
|
64
107
|
const result = asVNode(Element({ contentDirection: 'inline', children: 'test' }))
|
|
65
|
-
expect(result.
|
|
108
|
+
expect(getLayoutProps(result).direction).toBe('inline')
|
|
66
109
|
})
|
|
67
110
|
|
|
68
111
|
it('overrides alignX with contentAlignX when simple', () => {
|
|
69
112
|
const result = asVNode(Element({ contentAlignX: 'center', children: 'test' }))
|
|
70
|
-
expect(result.
|
|
113
|
+
expect(getLayoutProps(result).alignX).toBe('center')
|
|
71
114
|
})
|
|
72
115
|
|
|
73
116
|
it('overrides alignY with contentAlignY when simple', () => {
|
|
74
117
|
const result = asVNode(Element({ contentAlignY: 'top', children: 'test' }))
|
|
75
|
-
expect(result.
|
|
118
|
+
expect(getLayoutProps(result).alignY).toBe('top')
|
|
76
119
|
})
|
|
77
120
|
|
|
78
121
|
it('renders children directly via render() without Content wrappers', () => {
|
|
@@ -81,17 +124,17 @@ describe('Element', () => {
|
|
|
81
124
|
expect(slots).toHaveLength(0)
|
|
82
125
|
})
|
|
83
126
|
|
|
84
|
-
it('renders string children
|
|
127
|
+
it('renders string children directly as props.children', () => {
|
|
85
128
|
const result = asVNode(Element({ children: 'hello' }))
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
expect(
|
|
129
|
+
// Simple element fast path — passes children as a single value, not a
|
|
130
|
+
// 3-slot array wrapping falsy beforeContent/afterContent. This avoids
|
|
131
|
+
// 2 extra mountChild calls per Element in the common case.
|
|
132
|
+
expect(result.props.children).toBe('hello')
|
|
90
133
|
})
|
|
91
134
|
|
|
92
135
|
it('passes block prop to Wrapper', () => {
|
|
93
136
|
const result = asVNode(Element({ block: true, children: 'test' }))
|
|
94
|
-
expect(result.
|
|
137
|
+
expect(getLayoutProps(result).block).toBe(true)
|
|
95
138
|
})
|
|
96
139
|
})
|
|
97
140
|
|
|
@@ -104,7 +147,7 @@ describe('Element', () => {
|
|
|
104
147
|
afterContent: h('span', null, 'A'),
|
|
105
148
|
}),
|
|
106
149
|
)
|
|
107
|
-
expect(result.
|
|
150
|
+
expect(getLayoutProps(result).direction).toBe('inline')
|
|
108
151
|
})
|
|
109
152
|
|
|
110
153
|
it('uses explicit direction when provided', () => {
|
|
@@ -116,7 +159,7 @@ describe('Element', () => {
|
|
|
116
159
|
afterContent: h('span', null, 'A'),
|
|
117
160
|
}),
|
|
118
161
|
)
|
|
119
|
-
expect(result.
|
|
162
|
+
expect(getLayoutProps(result).direction).toBe('rows')
|
|
120
163
|
})
|
|
121
164
|
|
|
122
165
|
it('uses default alignX (left) and alignY (center)', () => {
|
|
@@ -127,8 +170,8 @@ describe('Element', () => {
|
|
|
127
170
|
afterContent: 'A',
|
|
128
171
|
}),
|
|
129
172
|
)
|
|
130
|
-
expect(result.
|
|
131
|
-
expect(result.
|
|
173
|
+
expect(getLayoutProps(result).alignX).toBe('left')
|
|
174
|
+
expect(getLayoutProps(result).alignY).toBe('center')
|
|
132
175
|
})
|
|
133
176
|
|
|
134
177
|
it('uses explicit alignX and alignY', () => {
|
|
@@ -141,8 +184,8 @@ describe('Element', () => {
|
|
|
141
184
|
afterContent: 'A',
|
|
142
185
|
}),
|
|
143
186
|
)
|
|
144
|
-
expect(result.
|
|
145
|
-
expect(result.
|
|
187
|
+
expect(getLayoutProps(result).alignX).toBe('center')
|
|
188
|
+
expect(getLayoutProps(result).alignY).toBe('top')
|
|
146
189
|
})
|
|
147
190
|
|
|
148
191
|
it('renders three Content children when both before and after exist', () => {
|
|
@@ -474,8 +517,8 @@ describe('Element', () => {
|
|
|
474
517
|
it('renders img with no children', () => {
|
|
475
518
|
// @ts-expect-error — testing element-specific attr forwarding
|
|
476
519
|
const result = asVNode(Element({ tag: 'img', src: '/pic.png' }))
|
|
477
|
-
expect(result.type).toBe(
|
|
478
|
-
expect(result.
|
|
520
|
+
expect(typeof result.type).toBe("function")
|
|
521
|
+
expect(getLayoutProps(result).tag).toBe('img')
|
|
479
522
|
expect(result.props.src).toBe('/pic.png')
|
|
480
523
|
expect(result.props.children).toBeUndefined()
|
|
481
524
|
})
|
|
@@ -483,52 +526,58 @@ describe('Element', () => {
|
|
|
483
526
|
it('renders input with no children', () => {
|
|
484
527
|
// @ts-expect-error — testing element-specific attr forwarding
|
|
485
528
|
const result = asVNode(Element({ tag: 'input', type: 'text' }))
|
|
486
|
-
expect(result.type).toBe(
|
|
487
|
-
expect(result.
|
|
529
|
+
expect(typeof result.type).toBe("function")
|
|
530
|
+
expect(getLayoutProps(result).tag).toBe('input')
|
|
488
531
|
expect(result.props.type).toBe('text')
|
|
489
532
|
expect(result.props.children).toBeUndefined()
|
|
490
533
|
})
|
|
491
534
|
|
|
492
535
|
it('renders with dangerouslySetInnerHTML (treated as empty)', () => {
|
|
493
536
|
const result = asVNode(Element({ dangerouslySetInnerHTML: { __html: '<b>hi</b>' } }))
|
|
494
|
-
expect(result.type).toBe(
|
|
537
|
+
expect(typeof result.type).toBe("function")
|
|
495
538
|
expect(result.props.dangerouslySetInnerHTML).toEqual({ __html: '<b>hi</b>' })
|
|
496
539
|
expect(result.props.children).toBeUndefined()
|
|
497
540
|
})
|
|
498
541
|
|
|
499
542
|
it('renders br with no children', () => {
|
|
500
543
|
const result = asVNode(Element({ tag: 'br' }))
|
|
501
|
-
expect(result.type).toBe(
|
|
544
|
+
expect(typeof result.type).toBe("function")
|
|
502
545
|
expect(result.props.children).toBeUndefined()
|
|
503
546
|
})
|
|
504
547
|
|
|
505
548
|
it('renders hr with no children', () => {
|
|
506
549
|
const result = asVNode(Element({ tag: 'hr' }))
|
|
507
|
-
expect(result.type).toBe(
|
|
550
|
+
expect(typeof result.type).toBe("function")
|
|
508
551
|
expect(result.props.children).toBeUndefined()
|
|
509
552
|
})
|
|
510
553
|
})
|
|
511
554
|
|
|
512
|
-
describe('
|
|
513
|
-
|
|
555
|
+
describe('inline-vs-block tag rendering', () => {
|
|
556
|
+
// The simple-element fast path inlines Wrapper into Styled and forwards
|
|
557
|
+
// `tag` as the `as` prop. The pre-fast-path `isInline` flag was an
|
|
558
|
+
// internal Wrapper plumbing detail that determined the inner SUB_TAG
|
|
559
|
+
// for the rare needsFix (button/fieldset/legend) path — invisible on
|
|
560
|
+
// span/a/section, which never need that fix. After the fast path the
|
|
561
|
+
// flag is gone in the simple path; the rendered tag is the contract.
|
|
562
|
+
it('renders inline tags like span as <span>', () => {
|
|
514
563
|
const result = asVNode(Element({ tag: 'span', children: 'text' }))
|
|
515
|
-
expect(result.
|
|
564
|
+
expect(getLayoutProps(result).tag).toBe('span')
|
|
516
565
|
})
|
|
517
566
|
|
|
518
|
-
it('
|
|
567
|
+
it('renders anchor tag as <a>', () => {
|
|
519
568
|
// @ts-expect-error — testing element-specific attr forwarding
|
|
520
569
|
const result = asVNode(Element({ tag: 'a', href: '#', children: 'link' }))
|
|
521
|
-
expect(result.
|
|
570
|
+
expect(getLayoutProps(result).tag).toBe('a')
|
|
522
571
|
})
|
|
523
572
|
|
|
524
|
-
it('
|
|
573
|
+
it('renders block tags like section as <section>', () => {
|
|
525
574
|
const result = asVNode(Element({ tag: 'section', children: 'text' }))
|
|
526
|
-
expect(result.
|
|
575
|
+
expect(getLayoutProps(result).tag).toBe('section')
|
|
527
576
|
})
|
|
528
577
|
|
|
529
|
-
it('
|
|
578
|
+
it('leaves tag undefined when not specified (default div)', () => {
|
|
530
579
|
const result = asVNode(Element({ children: 'text' }))
|
|
531
|
-
expect(result.
|
|
580
|
+
expect(getLayoutProps(result).tag).toBeUndefined()
|
|
532
581
|
})
|
|
533
582
|
})
|
|
534
583
|
|
|
@@ -536,21 +585,21 @@ describe('Element', () => {
|
|
|
536
585
|
it('passes css prop as extendCss to Wrapper', () => {
|
|
537
586
|
const customCss = 'color: red;'
|
|
538
587
|
const result = asVNode(Element({ css: customCss, children: 'test' }))
|
|
539
|
-
expect(result.
|
|
588
|
+
expect(getLayoutProps(result).extendCss).toBe(customCss)
|
|
540
589
|
})
|
|
541
590
|
|
|
542
591
|
it('does not pass extendCss when css not provided', () => {
|
|
543
592
|
const result = asVNode(Element({ children: 'test' }))
|
|
544
|
-
expect(result.
|
|
593
|
+
expect(getLayoutProps(result).extendCss).toBeUndefined()
|
|
545
594
|
})
|
|
546
595
|
})
|
|
547
596
|
|
|
548
597
|
describe('content fallback chain', () => {
|
|
549
598
|
it('prefers children over content', () => {
|
|
550
599
|
const result = asVNode(Element({ children: 'child', content: 'alt' }))
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
expect(
|
|
600
|
+
// Simple-element fast path returns children directly. The fallback
|
|
601
|
+
// chain (children → content → label) is exercised inside getChildren().
|
|
602
|
+
expect(result.props.children).toBe('child')
|
|
554
603
|
})
|
|
555
604
|
|
|
556
605
|
it('falls back to content when no children', () => {
|
|
@@ -576,13 +625,13 @@ describe('Element', () => {
|
|
|
576
625
|
describe('button tag (flex fix needed)', () => {
|
|
577
626
|
it('passes tag as button to Wrapper', () => {
|
|
578
627
|
const result = asVNode(Element({ tag: 'button', children: 'click' }))
|
|
579
|
-
expect(result.type).toBe(
|
|
580
|
-
expect(result.
|
|
628
|
+
expect(typeof result.type).toBe("function")
|
|
629
|
+
expect(getLayoutProps(result).tag).toBe('button')
|
|
581
630
|
})
|
|
582
631
|
|
|
583
632
|
it('passes isInline=true for button (inline element)', () => {
|
|
584
633
|
const result = asVNode(Element({ tag: 'button', children: 'click' }))
|
|
585
|
-
expect(result.
|
|
634
|
+
expect(getLayoutProps(result).isInline).toBe(true)
|
|
586
635
|
})
|
|
587
636
|
})
|
|
588
637
|
|
|
@@ -27,13 +27,13 @@ describe('Element equalBeforeAfter', () => {
|
|
|
27
27
|
children: 'Main',
|
|
28
28
|
}),
|
|
29
29
|
)
|
|
30
|
-
expect(result.type).toBe(
|
|
30
|
+
expect(typeof result.type).toBe("function")
|
|
31
31
|
expect(typeof result.props.ref).toBe('function')
|
|
32
32
|
})
|
|
33
33
|
|
|
34
34
|
it('does not crash without before/after content', () => {
|
|
35
35
|
const result = asVNode(Element({ equalBeforeAfter: true, children: 'Main only' }))
|
|
36
|
-
expect(result.type).toBe(
|
|
36
|
+
expect(typeof result.type).toBe("function")
|
|
37
37
|
})
|
|
38
38
|
|
|
39
39
|
it('does not crash with only beforeContent', () => {
|
|
@@ -44,7 +44,7 @@ describe('Element equalBeforeAfter', () => {
|
|
|
44
44
|
children: 'Main',
|
|
45
45
|
}),
|
|
46
46
|
)
|
|
47
|
-
expect(result.type).toBe(
|
|
47
|
+
expect(typeof result.type).toBe("function")
|
|
48
48
|
})
|
|
49
49
|
|
|
50
50
|
it('does not crash with only afterContent', () => {
|
|
@@ -55,7 +55,7 @@ describe('Element equalBeforeAfter', () => {
|
|
|
55
55
|
children: 'Main',
|
|
56
56
|
}),
|
|
57
57
|
)
|
|
58
|
-
expect(result.type).toBe(
|
|
58
|
+
expect(typeof result.type).toBe("function")
|
|
59
59
|
})
|
|
60
60
|
|
|
61
61
|
it('renders three slot children when both before and after exist', () => {
|
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
import type { VNode } from '@pyreon/core'
|
|
2
2
|
import { describe, expect, it, vi } from 'vitest'
|
|
3
3
|
|
|
4
|
+
// Element's simple-Element fast path inlines the Wrapper helper directly into
|
|
5
|
+
// a Styled invocation, so layout props move from `result.props.{tag, direction, …}`
|
|
6
|
+
// to `result.props.{as, $element.direction, …}`. This helper reads from
|
|
7
|
+
// whichever shape the result is in so assertions don't depend on which path.
|
|
8
|
+
const getLayoutProps = (result: VNode): Record<string, unknown> => {
|
|
9
|
+
const p = result.props as Record<string, unknown>
|
|
10
|
+
if (p.$element && typeof p.$element === 'object') {
|
|
11
|
+
const el = p.$element as Record<string, unknown>
|
|
12
|
+
return {
|
|
13
|
+
tag: p.as,
|
|
14
|
+
direction: el.direction,
|
|
15
|
+
alignX: el.alignX,
|
|
16
|
+
alignY: el.alignY,
|
|
17
|
+
block: el.block,
|
|
18
|
+
equalCols: el.equalCols,
|
|
19
|
+
extendCss: el.extraStyles,
|
|
20
|
+
isInline: undefined,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
tag: p.tag,
|
|
25
|
+
direction: p.direction,
|
|
26
|
+
alignX: p.alignX,
|
|
27
|
+
alignY: p.alignY,
|
|
28
|
+
block: p.block,
|
|
29
|
+
equalCols: p.equalCols,
|
|
30
|
+
extendCss: p.extendCss,
|
|
31
|
+
isInline: p.isInline,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
4
35
|
// ---------------------------------------------------------------------------
|
|
5
36
|
// Mocks — match patterns from existing element tests
|
|
6
37
|
// ---------------------------------------------------------------------------
|
|
@@ -36,7 +67,7 @@ const getContentSlots = (result: VNode): VNode[] => {
|
|
|
36
67
|
describe('Element integration', () => {
|
|
37
68
|
it('renders with content prop producing Wrapper VNode', () => {
|
|
38
69
|
const result = asVNode(Element({ content: 'hello world', children: undefined }))
|
|
39
|
-
expect(result.type).toBe(
|
|
70
|
+
expect(typeof result.type).toBe("function")
|
|
40
71
|
const children = result.props.children
|
|
41
72
|
expect(children).toBeDefined()
|
|
42
73
|
})
|
|
@@ -50,7 +81,7 @@ describe('Element integration', () => {
|
|
|
50
81
|
children: undefined,
|
|
51
82
|
}),
|
|
52
83
|
)
|
|
53
|
-
expect(result.type).toBe(
|
|
84
|
+
expect(typeof result.type).toBe("function")
|
|
54
85
|
const slots = getContentSlots(result)
|
|
55
86
|
// With before/after content, there should be Content wrapper VNodes
|
|
56
87
|
expect(slots.length).toBeGreaterThanOrEqual(2)
|
|
@@ -67,7 +98,7 @@ describe('Element integration', () => {
|
|
|
67
98
|
|
|
68
99
|
// When beforeContent/afterContent are absent, direction falls through
|
|
69
100
|
// to wrapper level and contentDirection/contentAlignX take effect
|
|
70
|
-
expect(result.type).toBe(
|
|
101
|
+
expect(typeof result.type).toBe("function")
|
|
71
102
|
// The wrapper receives alignment props
|
|
72
103
|
expect(result.props).toBeDefined()
|
|
73
104
|
})
|
|
@@ -81,7 +112,7 @@ describe('Element integration', () => {
|
|
|
81
112
|
}),
|
|
82
113
|
)
|
|
83
114
|
// Simple element (no before/after) uses contentDirection as wrapper direction
|
|
84
|
-
expect(result.
|
|
85
|
-
expect(result.
|
|
115
|
+
expect(getLayoutProps(result).direction).toBe('inline')
|
|
116
|
+
expect(getLayoutProps(result).alignX).toBe('center')
|
|
86
117
|
})
|
|
87
118
|
})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wall-clock stress benchmark for the Element + Wrapper + Styled stack.
|
|
3
|
+
*
|
|
4
|
+
* Runs in real Chromium. Goal: surface a measurable wall-clock delta that
|
|
5
|
+
* synthetic counter probes (happy-dom + mountChild) miss. Specifically
|
|
6
|
+
* targets the path where Pyreon's 9ms benchmark numbers come from — the
|
|
7
|
+
* mount-pipeline + styler-resolve composition.
|
|
8
|
+
*
|
|
9
|
+
* Each test mounts N components, disposes, and reports median wall-clock
|
|
10
|
+
* across 5 measured iterations after warmup. Variance ≤ 15% on stable runs.
|
|
11
|
+
*/
|
|
12
|
+
import { h, type VNodeChild } from '@pyreon/core'
|
|
13
|
+
import { mount } from '@pyreon/runtime-dom'
|
|
14
|
+
import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
|
|
15
|
+
import { describe, expect, it } from 'vitest'
|
|
16
|
+
import Element from '../Element/component'
|
|
17
|
+
|
|
18
|
+
interface Bench {
|
|
19
|
+
median: number
|
|
20
|
+
min: number
|
|
21
|
+
max: number
|
|
22
|
+
runs: number[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function benchmark(N: number, mountFn: (root: Element, i: number) => () => void): Promise<Bench> {
|
|
26
|
+
const { container, unmount: cleanup } = mountInBrowser(h('div', { id: 'bench-root' }))
|
|
27
|
+
const root = container.querySelector('#bench-root')!
|
|
28
|
+
|
|
29
|
+
// Warmup — primes the styler sheet cache + GC any dead objects from prior runs.
|
|
30
|
+
for (let w = 0; w < 50; w++) {
|
|
31
|
+
const dispose = mountFn(root, w)
|
|
32
|
+
dispose()
|
|
33
|
+
}
|
|
34
|
+
await flush()
|
|
35
|
+
|
|
36
|
+
const runs: number[] = []
|
|
37
|
+
for (let r = 0; r < 5; r++) {
|
|
38
|
+
const t0 = performance.now()
|
|
39
|
+
const disposers: Array<() => void> = []
|
|
40
|
+
for (let i = 0; i < N; i++) disposers.push(mountFn(root, i))
|
|
41
|
+
for (const d of disposers) d()
|
|
42
|
+
runs.push(performance.now() - t0)
|
|
43
|
+
await flush()
|
|
44
|
+
}
|
|
45
|
+
cleanup()
|
|
46
|
+
|
|
47
|
+
const sorted = [...runs].sort((a, b) => a - b)
|
|
48
|
+
const median = sorted[2] as number
|
|
49
|
+
const min = sorted[0] as number
|
|
50
|
+
const max = sorted[4] as number
|
|
51
|
+
return { median, min, max, runs }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('Element + stack stress benchmark', () => {
|
|
55
|
+
it('500 bare Element mounts (mount + dispose, batched)', async () => {
|
|
56
|
+
const bench = await benchmark(500, (root, i) => mount(h(Element, null, `item-${i}`), root))
|
|
57
|
+
// oxlint-disable-next-line no-console
|
|
58
|
+
console.log(
|
|
59
|
+
`[stress] 500 bare Element: median=${bench.median.toFixed(2)}ms, runs=[${bench.runs.map((r) => r.toFixed(1)).join(', ')}]`,
|
|
60
|
+
)
|
|
61
|
+
expect(bench.median).toBeLessThan(200)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('500 Element with css prop (exercises extendCss path)', async () => {
|
|
65
|
+
const bench = await benchmark(500, (root, i) =>
|
|
66
|
+
mount(
|
|
67
|
+
h(Element, { css: { color: 'red', padding: 8 } as unknown as Record<string, unknown> }, `item-${i}`),
|
|
68
|
+
root,
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
// oxlint-disable-next-line no-console
|
|
72
|
+
console.log(
|
|
73
|
+
`[stress] 500 Element + css: median=${bench.median.toFixed(2)}ms, runs=[${bench.runs.map((r) => r.toFixed(1)).join(', ')}]`,
|
|
74
|
+
)
|
|
75
|
+
expect(bench.median).toBeLessThan(500)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('depth-10 Element nesting × 50 mounts', async () => {
|
|
79
|
+
const buildDepth = (n: number, label: string): VNodeChild => {
|
|
80
|
+
if (n === 0) return label
|
|
81
|
+
return h(Element, null, buildDepth(n - 1, label))
|
|
82
|
+
}
|
|
83
|
+
const bench = await benchmark(50, (root, i) =>
|
|
84
|
+
mount(buildDepth(10, `leaf-${i}`) as unknown as Parameters<typeof mount>[0], root),
|
|
85
|
+
)
|
|
86
|
+
// oxlint-disable-next-line no-console
|
|
87
|
+
console.log(
|
|
88
|
+
`[stress] 50 depth-10: median=${bench.median.toFixed(2)}ms, runs=[${bench.runs.map((r) => r.toFixed(1)).join(', ')}]`,
|
|
89
|
+
)
|
|
90
|
+
expect(bench.median).toBeLessThan(500)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// Larger workload — clearer signal-to-noise. Mount + dispose 5000 elements.
|
|
94
|
+
it('5000 Element mounts — large workload, clearer wall-clock signal', async () => {
|
|
95
|
+
const bench = await benchmark(5000, (root, i) => mount(h(Element, null, `item-${i}`), root))
|
|
96
|
+
// oxlint-disable-next-line no-console
|
|
97
|
+
console.log(
|
|
98
|
+
`[stress] 5000 bare Element: median=${bench.median.toFixed(2)}ms, runs=[${bench.runs.map((r) => r.toFixed(1)).join(', ')}]`,
|
|
99
|
+
)
|
|
100
|
+
expect(bench.median).toBeLessThan(2000)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// One-shot single-tree mount: reflects real-app cold-mount cost.
|
|
104
|
+
it('single one-shot mount of a 500-Element tree', async () => {
|
|
105
|
+
const bench = await benchmark(1, (root) => {
|
|
106
|
+
const tree = h(
|
|
107
|
+
'div',
|
|
108
|
+
null,
|
|
109
|
+
...Array.from({ length: 500 }, (_, i) => h(Element, null, `child-${i}`)),
|
|
110
|
+
)
|
|
111
|
+
return mount(tree, root)
|
|
112
|
+
})
|
|
113
|
+
// oxlint-disable-next-line no-console
|
|
114
|
+
console.log(
|
|
115
|
+
`[stress] 500-child tree mount: median=${bench.median.toFixed(2)}ms, runs=[${bench.runs.map((r) => r.toFixed(1)).join(', ')}]`,
|
|
116
|
+
)
|
|
117
|
+
expect(bench.median).toBeLessThan(1000)
|
|
118
|
+
})
|
|
119
|
+
})
|