@pyreon/elements 0.14.0 → 0.16.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 +113 -56
- package/lib/index.js +200 -180
- package/package.json +14 -13
- package/src/Element/component.tsx +16 -4
- package/src/List/component.tsx +65 -15
- package/src/Overlay/component.tsx +6 -1
- package/src/Overlay/context.tsx +5 -1
- package/src/Portal/component.tsx +23 -10
- package/src/__tests__/Element.test.ts +157 -0
- package/src/__tests__/Iterator.test.ts +12 -3
- package/src/__tests__/Iterator.types.test.ts +237 -0
- package/src/__tests__/Overlay.test.ts +8 -1
- package/src/__tests__/Portal.test.ts +122 -48
- package/src/__tests__/Wrapper-innerhtml.test.tsx +178 -0
- package/src/__tests__/Wrapper.test.tsx +44 -0
- package/src/__tests__/elements.browser.test.tsx +77 -4
- package/src/__tests__/internElementBundle.test.ts +102 -0
- package/src/__tests__/native-markers.test.ts +13 -0
- package/src/__tests__/useOverlay.test.ts +7 -1
- package/src/__tests__/wrapper-block-cascade.test.ts +121 -0
- package/src/env.d.ts +6 -0
- package/src/helpers/Iterator/component.tsx +55 -4
- package/src/helpers/Iterator/index.ts +17 -1
- package/src/helpers/Iterator/types.ts +97 -38
- package/src/helpers/Wrapper/component.tsx +67 -30
- package/src/helpers/Wrapper/styled.ts +12 -18
- package/src/helpers/internElementBundle.ts +37 -0
- package/src/index.ts +4 -0
- package/src/types.ts +33 -2
- package/src/utils.ts +1 -4
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
|
@@ -37,7 +37,14 @@ vi.mock('@pyreon/reactivity', () => {
|
|
|
37
37
|
return s
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
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
|
|
@@ -1,68 +1,142 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import { h } from '@pyreon/core'
|
|
2
|
+
import { mount } from '@pyreon/runtime-dom'
|
|
3
3
|
import { describe, expect, it } from 'vitest'
|
|
4
4
|
import { Portal } from '../Portal'
|
|
5
5
|
|
|
6
|
-
const asVNode = (v: unknown) => v as VNode
|
|
7
|
-
|
|
8
6
|
describe('Portal', () => {
|
|
9
|
-
describe('
|
|
10
|
-
it('
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
7
|
+
describe('wrapper element creation', () => {
|
|
8
|
+
it('creates a per-instance wrapper appended to document.body by default', () => {
|
|
9
|
+
const before = document.body.children.length
|
|
10
|
+
const root = document.createElement('div')
|
|
11
|
+
document.body.appendChild(root)
|
|
12
|
+
|
|
13
|
+
const unmount = mount(h(Portal, { children: h('span', { id: 'pchild' }, 'modal') }), root)
|
|
14
|
+
|
|
15
|
+
// Wrapper appended directly to document.body (not inside `root`).
|
|
16
|
+
expect(document.body.children.length).toBe(before + 2) // root + portal wrapper
|
|
17
|
+
const wrapper = document.body.querySelector('#pchild')!.parentElement!
|
|
18
|
+
expect(wrapper).not.toBe(document.body)
|
|
19
|
+
expect(wrapper.tagName).toBe('DIV')
|
|
20
|
+
expect(wrapper.parentElement).toBe(document.body)
|
|
15
21
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const result = asVNode(Portal({ children: child }))
|
|
19
|
-
const props = result.props as Record<string, unknown>
|
|
20
|
-
expect(props.target).toBe(document.body)
|
|
22
|
+
unmount()
|
|
23
|
+
root.remove()
|
|
21
24
|
})
|
|
22
25
|
|
|
23
|
-
it('uses
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
26
|
+
it('uses the supplied tag for the wrapper element', () => {
|
|
27
|
+
const root = document.createElement('div')
|
|
28
|
+
document.body.appendChild(root)
|
|
29
|
+
|
|
30
|
+
const unmount = mount(
|
|
31
|
+
h(Portal, { tag: 'section', children: h('span', { id: 'tagchild' }, 'x') }),
|
|
32
|
+
root,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const wrapper = document.body.querySelector('#tagchild')!.parentElement!
|
|
36
|
+
expect(wrapper.tagName).toBe('SECTION')
|
|
37
|
+
|
|
38
|
+
unmount()
|
|
39
|
+
root.remove()
|
|
29
40
|
})
|
|
30
41
|
|
|
31
|
-
it('
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
42
|
+
it('appends the wrapper to DOMLocation when provided', () => {
|
|
43
|
+
const root = document.createElement('div')
|
|
44
|
+
const customTarget = document.createElement('article')
|
|
45
|
+
customTarget.id = 'custom-target'
|
|
46
|
+
document.body.appendChild(root)
|
|
47
|
+
document.body.appendChild(customTarget)
|
|
48
|
+
|
|
49
|
+
const unmount = mount(
|
|
50
|
+
h(Portal, { DOMLocation: customTarget, children: h('span', { id: 'cchild' }, 'inside') }),
|
|
51
|
+
root,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const wrapper = customTarget.querySelector('#cchild')!.parentElement!
|
|
55
|
+
expect(wrapper.parentElement).toBe(customTarget)
|
|
56
|
+
|
|
57
|
+
unmount()
|
|
58
|
+
root.remove()
|
|
59
|
+
customTarget.remove()
|
|
36
60
|
})
|
|
37
61
|
|
|
38
|
-
it('
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
62
|
+
it('renders children inside the wrapper', () => {
|
|
63
|
+
const root = document.createElement('div')
|
|
64
|
+
document.body.appendChild(root)
|
|
65
|
+
|
|
66
|
+
const unmount = mount(
|
|
67
|
+
h(Portal, { children: h('span', { id: 'inside-wrapper', class: 'modal' }, 'Modal') }),
|
|
68
|
+
root,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
const child = document.body.querySelector('#inside-wrapper')!
|
|
72
|
+
expect(child.textContent).toBe('Modal')
|
|
73
|
+
const wrapper = child.parentElement!
|
|
74
|
+
expect(wrapper.parentElement).toBe(document.body)
|
|
75
|
+
|
|
76
|
+
unmount()
|
|
77
|
+
root.remove()
|
|
42
78
|
})
|
|
43
79
|
|
|
44
|
-
it('
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
80
|
+
it('removes the wrapper from the DOM on unmount', () => {
|
|
81
|
+
const root = document.createElement('div')
|
|
82
|
+
document.body.appendChild(root)
|
|
83
|
+
|
|
84
|
+
const before = document.body.children.length
|
|
85
|
+
const unmount = mount(
|
|
86
|
+
h(Portal, { children: h('span', { id: 'cleanup-child' }, 'x') }),
|
|
87
|
+
root,
|
|
88
|
+
)
|
|
89
|
+
expect(document.body.children.length).toBe(before + 1) // wrapper added
|
|
90
|
+
const wrapper = document.body.querySelector('#cleanup-child')!.parentElement!
|
|
91
|
+
expect(wrapper.isConnected).toBe(true)
|
|
92
|
+
|
|
93
|
+
unmount()
|
|
94
|
+
expect(wrapper.isConnected).toBe(false)
|
|
95
|
+
expect(document.body.contains(wrapper)).toBe(false)
|
|
96
|
+
|
|
97
|
+
root.remove()
|
|
48
98
|
})
|
|
49
99
|
|
|
50
|
-
it('
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
100
|
+
it('removes the wrapper from a custom DOMLocation on unmount', () => {
|
|
101
|
+
const root = document.createElement('div')
|
|
102
|
+
const customTarget = document.createElement('div')
|
|
103
|
+
document.body.appendChild(root)
|
|
104
|
+
document.body.appendChild(customTarget)
|
|
105
|
+
|
|
106
|
+
const unmount = mount(
|
|
107
|
+
h(Portal, { DOMLocation: customTarget, children: h('span', { id: 'cu' }, 'x') }),
|
|
108
|
+
root,
|
|
109
|
+
)
|
|
110
|
+
expect(customTarget.children.length).toBe(1)
|
|
111
|
+
|
|
112
|
+
unmount()
|
|
113
|
+
expect(customTarget.children.length).toBe(0)
|
|
114
|
+
|
|
115
|
+
root.remove()
|
|
116
|
+
customTarget.remove()
|
|
57
117
|
})
|
|
58
|
-
})
|
|
59
118
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
119
|
+
it('isolates per-instance wrappers when multiple Portals share a DOMLocation', () => {
|
|
120
|
+
const root = document.createElement('div')
|
|
121
|
+
document.body.appendChild(root)
|
|
122
|
+
|
|
123
|
+
const u1 = mount(h(Portal, { children: h('span', { id: 'p1' }, 'A') }), root)
|
|
124
|
+
const u2 = mount(h(Portal, { children: h('span', { id: 'p2' }, 'B') }), root)
|
|
125
|
+
|
|
126
|
+
const w1 = document.body.querySelector('#p1')!.parentElement!
|
|
127
|
+
const w2 = document.body.querySelector('#p2')!.parentElement!
|
|
128
|
+
expect(w1).not.toBe(w2)
|
|
129
|
+
expect(w1.parentElement).toBe(document.body)
|
|
130
|
+
expect(w2.parentElement).toBe(document.body)
|
|
131
|
+
|
|
132
|
+
u1()
|
|
133
|
+
// unmounting one Portal removes only its wrapper, not the sibling's
|
|
134
|
+
expect(w1.isConnected).toBe(false)
|
|
135
|
+
expect(w2.isConnected).toBe(true)
|
|
136
|
+
|
|
137
|
+
u2()
|
|
138
|
+
expect(w2.isConnected).toBe(false)
|
|
139
|
+
root.remove()
|
|
66
140
|
})
|
|
67
141
|
})
|
|
68
142
|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression: Wrapper used to silently drop `dangerouslySetInnerHTML`.
|
|
3
|
+
*
|
|
4
|
+
* Bug shape: `OWN_KEYS` listed `'dangerouslySetInnerHTML'`, so `splitProps`
|
|
5
|
+
* moved it into `own`. The Styled JSX call only spread `...commonProps`
|
|
6
|
+
* (built from `rest`) and never re-attached `own.dangerouslySetInnerHTML`.
|
|
7
|
+
* Both runtimes (`runtime-server` and `runtime-dom`) support the prop —
|
|
8
|
+
* the data was lost between Wrapper and the renderer.
|
|
9
|
+
*
|
|
10
|
+
* Two test layers:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Mock-vnode tests** (this file's first describe block) — fast
|
|
13
|
+
* structural assertions against the vnode tree Wrapper returns. Catches
|
|
14
|
+
* the prop drop at the API surface where it originally happened.
|
|
15
|
+
*
|
|
16
|
+
* 2. **Real-h() mount tests** (second describe block) — uses real `h()` +
|
|
17
|
+
* `mount()` to exercise the full Element → Wrapper → Styled → DOM
|
|
18
|
+
* pipeline. Catches the prop drop wherever it might occur along the
|
|
19
|
+
* chain (Wrapper, Element, rocketstyle attrs HOC, runtime-dom prop
|
|
20
|
+
* application). This is the "safety net" pattern from
|
|
21
|
+
* .claude/rules/test-environment-parity.md — mock-vnode tests bypass
|
|
22
|
+
* the HOC + mount pipeline and CAN miss bugs that surface only when
|
|
23
|
+
* the real `h()` + mount path runs, exactly like PR #197's silent
|
|
24
|
+
* metadata drop. Always have both.
|
|
25
|
+
*/
|
|
26
|
+
import { h, type VNode } from '@pyreon/core'
|
|
27
|
+
import { mount } from '@pyreon/runtime-dom'
|
|
28
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
29
|
+
|
|
30
|
+
vi.mock('~/utils', () => ({
|
|
31
|
+
IS_DEVELOPMENT: false,
|
|
32
|
+
}))
|
|
33
|
+
|
|
34
|
+
import Wrapper from '../helpers/Wrapper/component'
|
|
35
|
+
import { Element } from '../Element'
|
|
36
|
+
|
|
37
|
+
const asVNode = (v: unknown) => v as VNode
|
|
38
|
+
|
|
39
|
+
describe('Wrapper — dangerouslySetInnerHTML forwarding (mock-vnode)', () => {
|
|
40
|
+
it('forwards dangerouslySetInnerHTML to the rendered Styled vnode (non-needsFix path)', () => {
|
|
41
|
+
const html = { __html: '<svg>x</svg>' }
|
|
42
|
+
const result = asVNode(
|
|
43
|
+
Wrapper({
|
|
44
|
+
tag: 'div',
|
|
45
|
+
dangerouslySetInnerHTML: html,
|
|
46
|
+
}),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
// Bug-shape assertion: the prop must reach the rendered vnode.
|
|
50
|
+
// Pre-fix this is `undefined` → SVG is silently dropped.
|
|
51
|
+
expect(result.props.dangerouslySetInnerHTML).toBe(html)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('drops children when dangerouslySetInnerHTML is present (mutually exclusive)', () => {
|
|
55
|
+
const html = { __html: '<svg>x</svg>' }
|
|
56
|
+
const result = asVNode(
|
|
57
|
+
Wrapper({
|
|
58
|
+
tag: 'div',
|
|
59
|
+
dangerouslySetInnerHTML: html,
|
|
60
|
+
// children would conflict — innerHTML wins.
|
|
61
|
+
children: 'should be dropped',
|
|
62
|
+
}),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
// children slot must not coexist with innerHTML — runtime-server's
|
|
66
|
+
// and runtime-dom's prop pipeline both treat them as inner-content
|
|
67
|
+
// sources, and emitting both would result in either a malformed
|
|
68
|
+
// tree or innerHTML being overwritten by the children mount.
|
|
69
|
+
expect(result.children).toEqual([])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('forwards dangerouslySetInnerHTML on the needsFix path (button/fieldset/legend)', () => {
|
|
73
|
+
// button/fieldset/legend take the two-layer flex fix path. innerHTML
|
|
74
|
+
// belongs on the inner styled node (where the actual content goes),
|
|
75
|
+
// NOT on the outer wrapper.
|
|
76
|
+
const html = { __html: '<span>label</span>' }
|
|
77
|
+
const result = asVNode(
|
|
78
|
+
Wrapper({
|
|
79
|
+
tag: 'button',
|
|
80
|
+
dangerouslySetInnerHTML: html,
|
|
81
|
+
}),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
// The `needsFix` branch should NOT trigger when innerHTML is set
|
|
85
|
+
// (innerHTML replaces all children, including the inner flex-fix
|
|
86
|
+
// layer). The simplest correct behavior: bypass needsFix when
|
|
87
|
+
// innerHTML is present and forward the prop on the single Styled.
|
|
88
|
+
expect(result.props.dangerouslySetInnerHTML).toBe(html)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// Real-h() mount tests — parallel coverage that runs the full pipeline.
|
|
93
|
+
// Element uses Wrapper internally; mounting Element with
|
|
94
|
+
// `dangerouslySetInnerHTML` exercises every layer the bug could surface
|
|
95
|
+
// at: Element's split → Wrapper → Styled → runtime-dom's prop application.
|
|
96
|
+
// happy-dom is the test environment; `dangerouslySetInnerHTML` translates
|
|
97
|
+
// to `el.innerHTML = ...` which happy-dom handles natively.
|
|
98
|
+
describe('Wrapper — dangerouslySetInnerHTML forwarding (real h() + mount)', () => {
|
|
99
|
+
it('Element with dangerouslySetInnerHTML actually injects HTML into the DOM (non-needsFix tag)', () => {
|
|
100
|
+
const root = document.createElement('div')
|
|
101
|
+
document.body.appendChild(root)
|
|
102
|
+
|
|
103
|
+
const unmount = mount(
|
|
104
|
+
h(Element, {
|
|
105
|
+
tag: 'div',
|
|
106
|
+
'data-testid': 'innerhtml-host',
|
|
107
|
+
dangerouslySetInnerHTML: { __html: '<svg data-marker="real-h-svg">x</svg>' },
|
|
108
|
+
}),
|
|
109
|
+
root,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
// The structural assertion: SVG element exists in the rendered DOM.
|
|
113
|
+
// Pre-fix Wrapper dropped the prop → no SVG → null query result.
|
|
114
|
+
const svg = root.querySelector('[data-marker="real-h-svg"]')
|
|
115
|
+
expect(svg).not.toBeNull()
|
|
116
|
+
expect(svg?.tagName.toLowerCase()).toBe('svg')
|
|
117
|
+
|
|
118
|
+
unmount()
|
|
119
|
+
root.remove()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('Element with dangerouslySetInnerHTML on a needsFix tag (button) still injects HTML', () => {
|
|
123
|
+
// button is a needsFix tag (two-layer flex fix). The Wrapper branch
|
|
124
|
+
// must bypass the two-layer fix when innerHTML is present, OR forward
|
|
125
|
+
// innerHTML to the right layer. Either way the rendered DOM must
|
|
126
|
+
// contain the user-supplied HTML.
|
|
127
|
+
const root = document.createElement('div')
|
|
128
|
+
document.body.appendChild(root)
|
|
129
|
+
|
|
130
|
+
const unmount = mount(
|
|
131
|
+
h(Element, {
|
|
132
|
+
tag: 'button',
|
|
133
|
+
'data-testid': 'innerhtml-button',
|
|
134
|
+
dangerouslySetInnerHTML: { __html: '<span data-marker="real-h-button-label">click me</span>' },
|
|
135
|
+
}),
|
|
136
|
+
root,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
const span = root.querySelector('[data-marker="real-h-button-label"]')
|
|
140
|
+
expect(span).not.toBeNull()
|
|
141
|
+
expect(span?.textContent).toBe('click me')
|
|
142
|
+
|
|
143
|
+
unmount()
|
|
144
|
+
root.remove()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('children passed alongside dangerouslySetInnerHTML are dropped (innerHTML wins)', () => {
|
|
148
|
+
// Bug shape: if Wrapper's `own.children` leaks into the rendered vnode
|
|
149
|
+
// alongside `dangerouslySetInnerHTML`, runtime-dom would either mount
|
|
150
|
+
// children INTO the innerHTML-populated element (overwriting), or land
|
|
151
|
+
// both side-by-side. The contract is that innerHTML wins and children
|
|
152
|
+
// are dropped. Verifying at the DOM level catches both failure shapes.
|
|
153
|
+
const root = document.createElement('div')
|
|
154
|
+
document.body.appendChild(root)
|
|
155
|
+
|
|
156
|
+
const unmount = mount(
|
|
157
|
+
h(
|
|
158
|
+
Element,
|
|
159
|
+
{
|
|
160
|
+
tag: 'div',
|
|
161
|
+
'data-testid': 'innerhtml-with-children',
|
|
162
|
+
dangerouslySetInnerHTML: { __html: '<i data-marker="real-h-winner">html wins</i>' },
|
|
163
|
+
},
|
|
164
|
+
'this child text should NOT appear',
|
|
165
|
+
),
|
|
166
|
+
root,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
const host = root.querySelector('[data-testid="innerhtml-with-children"]')!
|
|
170
|
+
expect(host.querySelector('[data-marker="real-h-winner"]')).not.toBeNull()
|
|
171
|
+
// The child string must not appear anywhere in the rendered host.
|
|
172
|
+
expect(host.textContent).not.toContain('this child text should NOT appear')
|
|
173
|
+
expect(host.textContent).toContain('html wins')
|
|
174
|
+
|
|
175
|
+
unmount()
|
|
176
|
+
root.remove()
|
|
177
|
+
})
|
|
178
|
+
})
|
|
@@ -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,81 @@ 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 —
|
|
56
|
-
|
|
57
|
-
|
|
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
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// Regression: Wrapper used to silently drop dangerouslySetInnerHTML.
|
|
87
|
+
// The unit test asserts Wrapper forwards the prop on the rendered
|
|
88
|
+
// vnode; this real-Chromium test asserts the SVG actually appears in
|
|
89
|
+
// the DOM after mount — the user-visible bug shape was "renders empty
|
|
90
|
+
// <div></div> instead of inlined SVG".
|
|
91
|
+
describe('dangerouslySetInnerHTML — real-Chromium DOM proof', () => {
|
|
92
|
+
it('inlines an SVG via Element + dangerouslySetInnerHTML', () => {
|
|
93
|
+
const { container, unmount } = mountInBrowser(
|
|
94
|
+
<Element
|
|
95
|
+
tag="div"
|
|
96
|
+
data-id="logo"
|
|
97
|
+
dangerouslySetInnerHTML={{
|
|
98
|
+
__html: '<svg viewBox="0 0 10 10"><rect width="10" height="10" /></svg>',
|
|
99
|
+
}}
|
|
100
|
+
/>,
|
|
101
|
+
)
|
|
102
|
+
const root = container.querySelector('[data-id="logo"]')
|
|
103
|
+
expect(root).toBeTruthy()
|
|
104
|
+
// The SVG must actually be in the DOM, not lost between Wrapper and
|
|
105
|
+
// the renderer. Pre-fix this assertion failed: container had
|
|
106
|
+
// <div data-id="logo"></div> with no children.
|
|
107
|
+
const svg = root?.querySelector('svg')
|
|
108
|
+
expect(svg).toBeTruthy()
|
|
109
|
+
expect(svg?.tagName.toLowerCase()).toBe('svg')
|
|
110
|
+
expect(svg?.querySelector('rect')).toBeTruthy()
|
|
111
|
+
unmount()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('inlines markup on a button (needsFix path)', () => {
|
|
115
|
+
const { container, unmount } = mountInBrowser(
|
|
116
|
+
<Element
|
|
117
|
+
tag="button"
|
|
118
|
+
data-id="btn"
|
|
119
|
+
dangerouslySetInnerHTML={{ __html: '<strong>Save</strong>' }}
|
|
120
|
+
/>,
|
|
121
|
+
)
|
|
122
|
+
const btn = container.querySelector('[data-id="btn"]')
|
|
123
|
+
expect(btn).toBeTruthy()
|
|
124
|
+
// The bold text must reach the DOM. The needsFix gate's existing
|
|
125
|
+
// `!own.dangerouslySetInnerHTML` clause skips the two-layer fix,
|
|
126
|
+
// so this falls into the !needsFix branch and the fix's
|
|
127
|
+
// `if (innerHTML)` path takes over.
|
|
128
|
+
expect(btn?.querySelector('strong')?.textContent).toBe('Save')
|
|
129
|
+
unmount()
|
|
130
|
+
})
|
|
58
131
|
})
|
|
59
132
|
})
|
|
@@ -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
|
+
})
|
|
@@ -34,7 +34,13 @@ vi.mock('@pyreon/reactivity', () => {
|
|
|
34
34
|
return s
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
// See sibling Overlay.test.ts mock: `@pyreon/core` imports
|
|
38
|
+
// `setSnapshotCapture` and calls it at module load to install the
|
|
39
|
+
// reactive-effect context-snapshot DI hook. The mock provides a no-op
|
|
40
|
+
// so the `@pyreon/core` import doesn't throw "No 'setSnapshotCapture'
|
|
41
|
+
// export is defined on the '@pyreon/reactivity' mock."
|
|
42
|
+
const setSnapshotCapture = () => {}
|
|
43
|
+
return { signal, setSnapshotCapture }
|
|
38
44
|
})
|
|
39
45
|
|
|
40
46
|
vi.mock('@pyreon/core', async (importOriginal) => {
|