@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.
@@ -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
- {isSimpleElement ? (
178
- render(getChildren())
179
- ) : (
180
- <Content
181
- tag={SUB_TAG}
182
- contentType="content"
183
- parentDirection={wrapperDirection}
184
- extendCss={own.contentCss}
185
- direction={contentDirection}
186
- alignX={contentAlignX}
187
- alignY={contentAlignY}
188
- equalCols={own.equalCols}
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(Wrapper)
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.props.tag).toBe('section')
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.props.tag).toBeUndefined()
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(Wrapper)
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.props.direction).toBe('rows')
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.props.alignX).toBe('left')
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.props.alignY).toBe('center')
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.props.direction).toBe('inline')
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.props.alignX).toBe('center')
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.props.alignY).toBe('top')
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 in props.children array', () => {
127
+ it('renders string children directly as props.children', () => {
85
128
  const result = asVNode(Element({ children: 'hello' }))
86
- const children = result.props.children as unknown[]
87
- // Simple element renders: [falsy beforeContent, render(CHILDREN), falsy afterContent]
88
- expect(children).toBeDefined()
89
- expect(Array.isArray(children)).toBe(true)
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.props.block).toBe(true)
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.props.direction).toBe('inline')
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.props.direction).toBe('rows')
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.props.alignX).toBe('left')
131
- expect(result.props.alignY).toBe('center')
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.props.alignX).toBe('center')
145
- expect(result.props.alignY).toBe('top')
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(Wrapper)
478
- expect(result.props.tag).toBe('img')
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(Wrapper)
487
- expect(result.props.tag).toBe('input')
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(Wrapper)
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(Wrapper)
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(Wrapper)
550
+ expect(typeof result.type).toBe("function")
508
551
  expect(result.props.children).toBeUndefined()
509
552
  })
510
553
  })
511
554
 
512
- describe('isInline flag for Wrapper', () => {
513
- it('passes isInline=true for inline tags like span', () => {
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.props.isInline).toBe(true)
564
+ expect(getLayoutProps(result).tag).toBe('span')
516
565
  })
517
566
 
518
- it('passes isInline=true for anchor tag', () => {
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.props.isInline).toBe(true)
570
+ expect(getLayoutProps(result).tag).toBe('a')
522
571
  })
523
572
 
524
- it('passes isInline=false for block tags like section', () => {
573
+ it('renders block tags like section as <section>', () => {
525
574
  const result = asVNode(Element({ tag: 'section', children: 'text' }))
526
- expect(result.props.isInline).toBe(false)
575
+ expect(getLayoutProps(result).tag).toBe('section')
527
576
  })
528
577
 
529
- it('passes isInline=false when tag is undefined (default)', () => {
578
+ it('leaves tag undefined when not specified (default div)', () => {
530
579
  const result = asVNode(Element({ children: 'text' }))
531
- expect(result.props.isInline).toBe(false)
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.props.extendCss).toBe(customCss)
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.props.extendCss).toBeUndefined()
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
- const children = result.props.children as unknown[]
552
- expect(children).toBeDefined()
553
- expect(Array.isArray(children)).toBe(true)
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(Wrapper)
580
- expect(result.props.tag).toBe('button')
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.props.isInline).toBe(true)
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(Wrapper)
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(Wrapper)
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(Wrapper)
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(Wrapper)
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(Wrapper)
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(Wrapper)
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(Wrapper)
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.props.direction).toBe('inline')
85
- expect(result.props.alignX).toBe('center')
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
+ })