@pyreon/elements 0.13.1 → 0.15.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.
@@ -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
 
@@ -37,7 +37,14 @@ vi.mock('@pyreon/reactivity', () => {
37
37
  return s
38
38
  }
39
39
 
40
- return { signal }
40
+ // No-op: `@pyreon/core/context.ts` calls `setSnapshotCapture(...)` at
41
+ // module load to install the reactive-effect context-snapshot DI hook.
42
+ // Mocked tests don't drive real reactive scoping, so accept the call
43
+ // and discard. Required since `@pyreon/core` imports `setSnapshotCapture`
44
+ // from `@pyreon/reactivity` — without this stub the mock factory throws
45
+ // "No 'setSnapshotCapture' export is defined on the '@pyreon/reactivity' mock."
46
+ const setSnapshotCapture = () => {}
47
+ return { signal, setSnapshotCapture }
41
48
  })
42
49
 
43
50
  // onMount / onUnmount are no-ops outside a renderer
@@ -149,4 +149,48 @@ describe('Wrapper component', () => {
149
149
  expect((result.props.$element as any).parentFix).toBeUndefined()
150
150
  })
151
151
  })
152
+
153
+ // Void HTML elements (hr, input, img, br, …) cannot have children. Element
154
+ // already skips passing children to Wrapper for void tags, but the JSX
155
+ // `{own.children}` slot still serialized `undefined` into the vnode and
156
+ // tripped runtime-dom's void-element warning. Wrapper now drops the slot
157
+ // entirely for void tags.
158
+ describe('void HTML elements drop the children slot', () => {
159
+ const VOID_TAGS = ['hr', 'input', 'img', 'br', 'area', 'base', 'col', 'embed', 'link', 'source', 'track', 'wbr'] as const
160
+
161
+ for (const tag of VOID_TAGS) {
162
+ it(`omits children for <${tag}>`, () => {
163
+ // Cast to any — the Wrapper Props.tag type narrows out void
164
+ // elements, but the runtime guard still has to cover the case
165
+ // where a void tag reaches Wrapper (e.g. via rocketstyle attrs
166
+ // composing `tag: 'hr'` from a callback whose return type is wider).
167
+ const result = asVNode(Wrapper({ tag } as any))
168
+ expect(result.type).toBe(Styled)
169
+ expect(result.props.children).toBeUndefined()
170
+ expect(result.children).toEqual([])
171
+ })
172
+ }
173
+
174
+ it('still renders children for non-void tags', () => {
175
+ const result = asVNode(Wrapper({ tag: 'div', children: 'kept' }))
176
+ expect(result.props.children).toBe('kept')
177
+ })
178
+
179
+ it('omits children even when caller accidentally passes them to a void tag', () => {
180
+ const result = asVNode(Wrapper({ tag: 'hr', children: 'should-not-leak' }))
181
+ expect(result.props.children).toBeUndefined()
182
+ })
183
+
184
+ it('dangerouslySetInnerHTML on a normally-void tag bypasses the void path', () => {
185
+ const result = asVNode(
186
+ Wrapper({
187
+ tag: 'hr',
188
+ dangerouslySetInnerHTML: { __html: '<b>x</b>' },
189
+ } as any),
190
+ )
191
+ // dangerouslySetInnerHTML opts out of the void-element guard
192
+ // because the tag becomes a custom element / shadow host case.
193
+ expect(result.type).toBe(Styled)
194
+ })
195
+ })
152
196
  })
@@ -1,5 +1,5 @@
1
1
  /** @jsxImportSource @pyreon/core */
2
- import { describe, expect, it } from 'vitest'
2
+ import { describe, expect, it, vi } from 'vitest'
3
3
  import { signal } from '@pyreon/reactivity'
4
4
  import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
5
5
  import { Element } from '../Element'
@@ -52,8 +52,34 @@ describe('@pyreon/elements browser smoke', () => {
52
52
  expect(document.querySelector('[data-portal-id="p"]')).toBeNull()
53
53
  })
54
54
 
55
- it('runs in a real browser — `typeof process` is undefined, `import.meta.env.DEV` is true', () => {
56
- expect(typeof process).toBe('undefined')
57
- expect(import.meta.env.DEV).toBe(true)
55
+ it('runs in a real browser — Vitest defines `process.env.NODE_ENV !== "production"`', () => {
56
+ // Sanity check the test env: dev gates use bundler-agnostic
57
+ // `process.env.NODE_ENV !== 'production'`. Every modern bundler
58
+ // (incl. Vitest's Vite) replaces this at build time. In a real-browser
59
+ // test run the literal lands as `"development" !== "production"` →
60
+ // `true`, so dev warnings fire as expected.
61
+ expect(process.env.NODE_ENV).not.toBe('production')
62
+ })
63
+
64
+ // Void HTML elements via Element must not trip runtime-dom's
65
+ // "<X> is a void element and cannot have children" warning.
66
+ // Wrapper used to leak an `undefined` child into the vnode for void tags.
67
+ describe('void HTML element tags', () => {
68
+ const voidTags: Array<'hr' | 'br' | 'input' | 'img'> = ['hr', 'br', 'input', 'img']
69
+
70
+ for (const tag of voidTags) {
71
+ it(`<${tag}> mounts without "void element" console.warn`, () => {
72
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
73
+ const { container, unmount } = mountInBrowser(<Element tag={tag} data-id={tag} />)
74
+ const el = container.querySelector(`[data-id="${tag}"]`)
75
+ expect(el?.tagName.toLowerCase()).toBe(tag)
76
+ const voidWarnings = warnSpy.mock.calls.filter((args) =>
77
+ typeof args[0] === 'string' && args[0].includes('void element'),
78
+ )
79
+ expect(voidWarnings).toEqual([])
80
+ warnSpy.mockRestore()
81
+ unmount()
82
+ })
83
+ }
58
84
  })
59
85
  })
@@ -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,102 @@
1
+ /**
2
+ * Unit tests for internElementBundle() — the module-scope LRU cache that
3
+ * gives same-shape Element layouts a stable object identity. Locks in the
4
+ * bail conditions (functions, non-string objects) and the LRU semantics
5
+ * so a future refactor can't silently break the styler classCache hit.
6
+ */
7
+ import { describe, expect, it } from 'vitest'
8
+ import { internElementBundle } from '../helpers/internElementBundle'
9
+
10
+ describe('internElementBundle', () => {
11
+ it('returns the same identity for the same primitive prop tuple', () => {
12
+ const a = internElementBundle({
13
+ block: false,
14
+ direction: 'inline',
15
+ alignX: 'left',
16
+ alignY: 'center',
17
+ equalCols: false,
18
+ extraStyles: undefined,
19
+ })
20
+ const b = internElementBundle({
21
+ block: false,
22
+ direction: 'inline',
23
+ alignX: 'left',
24
+ alignY: 'center',
25
+ equalCols: false,
26
+ extraStyles: undefined,
27
+ })
28
+ expect(a).toBe(b)
29
+ })
30
+
31
+ it('returns different identity when any primitive value differs', () => {
32
+ const a = internElementBundle({ block: false, direction: 'inline' })
33
+ const b = internElementBundle({ block: true, direction: 'inline' })
34
+ const c = internElementBundle({ block: false, direction: 'rows' })
35
+ expect(a).not.toBe(b)
36
+ expect(a).not.toBe(c)
37
+ expect(b).not.toBe(c)
38
+ })
39
+
40
+ it('handles different bundle shapes independently (parentFix vs childFix)', () => {
41
+ const parent = internElementBundle({
42
+ parentFix: true,
43
+ block: false,
44
+ extraStyles: undefined,
45
+ })
46
+ const child = internElementBundle({
47
+ childFix: true,
48
+ direction: 'inline',
49
+ alignX: 'left',
50
+ alignY: 'center',
51
+ equalCols: false,
52
+ })
53
+ expect(parent).not.toBe(child)
54
+ // Same shape repeats are interned
55
+ const parent2 = internElementBundle({
56
+ parentFix: true,
57
+ block: false,
58
+ extraStyles: undefined,
59
+ })
60
+ expect(parent).toBe(parent2)
61
+ })
62
+
63
+ it('bails (returns the input untouched) when a value is a function', () => {
64
+ const fn = () => 'red'
65
+ const a = internElementBundle({ block: false, extraStyles: fn })
66
+ const b = internElementBundle({ block: false, extraStyles: fn })
67
+ // Both calls bail — neither is cached, so identities differ.
68
+ expect(a).not.toBe(b)
69
+ // The returned object IS the input (untouched).
70
+ expect(a).toEqual({ block: false, extraStyles: fn })
71
+ })
72
+
73
+ it('bails when a value is a non-null object (CSSResult / nested object)', () => {
74
+ const cssResult = { strings: ['color: red'], values: [] }
75
+ const a = internElementBundle({ block: false, extraStyles: cssResult })
76
+ const b = internElementBundle({ block: false, extraStyles: cssResult })
77
+ expect(a).not.toBe(b)
78
+ })
79
+
80
+ it('treats string extraStyles as cacheable (the common pre-resolved CSS case)', () => {
81
+ const a = internElementBundle({ block: false, extraStyles: 'color: red' })
82
+ const b = internElementBundle({ block: false, extraStyles: 'color: red' })
83
+ expect(a).toBe(b)
84
+ const c = internElementBundle({ block: false, extraStyles: 'color: blue' })
85
+ expect(a).not.toBe(c)
86
+ })
87
+
88
+ it('null and undefined are distinct cache keys (JSON serializes them differently)', () => {
89
+ const aNull = internElementBundle({ extraStyles: null })
90
+ const aUndef = internElementBundle({ extraStyles: undefined })
91
+ expect(aNull).not.toBe(aUndef)
92
+ })
93
+
94
+ it('LRU touch on hit moves entry to most-recent position', () => {
95
+ // Hit the same bundle twice — second call should still return the same
96
+ // identity (LRU touch keeps it alive).
97
+ const key = `lru-touch-${Math.random()}`
98
+ const first = internElementBundle({ direction: key })
99
+ const second = internElementBundle({ direction: key })
100
+ expect(first).toBe(second)
101
+ })
102
+ })
@@ -0,0 +1,13 @@
1
+ import { isNativeCompat } from '@pyreon/core'
2
+ import { describe, expect, it } from 'vitest'
3
+ import Overlay from '../Overlay/component'
4
+ import OverlayContextProvider from '../Overlay/context'
5
+
6
+ describe('native-compat markers — @pyreon/elements', () => {
7
+ it('Overlay is marked native', () => {
8
+ expect(isNativeCompat(Overlay)).toBe(true)
9
+ })
10
+ it('Overlay context Provider is marked native', () => {
11
+ expect(isNativeCompat(OverlayContextProvider)).toBe(true)
12
+ })
13
+ })