@vobs/runtime 1.4.2 → 1.5.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/dist/async-boundary.cjs.map +1 -1
- package/dist/async-boundary.js.map +1 -1
- package/dist/bind.cjs.map +1 -1
- package/dist/bind.js.map +1 -1
- package/dist/boundary.cjs.map +1 -1
- package/dist/boundary.js.map +1 -1
- package/dist/dynamic.cjs.map +1 -1
- package/dist/dynamic.js.map +1 -1
- package/dist/error-boundary.cjs.map +1 -1
- package/dist/error-boundary.js.map +1 -1
- package/dist/fragment.cjs.map +1 -1
- package/dist/fragment.js.map +1 -1
- package/dist/index.cjs +18 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +18 -3
- package/dist/index.js.map +1 -1
- package/dist/ops.cjs +18 -3
- package/dist/ops.cjs.map +1 -1
- package/dist/ops.js +18 -3
- package/dist/ops.js.map +1 -1
- package/dist/profiler.cjs.map +1 -1
- package/dist/profiler.js.map +1 -1
- package/package.json +2 -2
- package/src/jsx-integration.test.ts +169 -0
- package/src/ops.ts +14 -2
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// JSX 编译产物 + runtime 集成回归:复现 Labelune 侧报过的两个场景
|
|
3
|
+
// ③ 列表内动态 style 表达式(品牌色板:动态 style 渲染不出来 → 全透明)
|
|
4
|
+
// ④ 列表条目内「节点↔null」条件(信号已 set 但节点不出现/不移除)
|
|
5
|
+
import { describe, expect, it } from 'vitest'
|
|
6
|
+
import { compile } from '@vobs/compiler'
|
|
7
|
+
import { createDOMRenderer } from '@vobs/vobs'
|
|
8
|
+
import {
|
|
9
|
+
createElement,
|
|
10
|
+
createText,
|
|
11
|
+
createTemplate,
|
|
12
|
+
cloneTemplate,
|
|
13
|
+
insertBefore,
|
|
14
|
+
insertDynamic,
|
|
15
|
+
insertList,
|
|
16
|
+
insertDynamicValue,
|
|
17
|
+
bindAttribute,
|
|
18
|
+
bindText,
|
|
19
|
+
bindProperty,
|
|
20
|
+
setStaticProps,
|
|
21
|
+
setProperty,
|
|
22
|
+
setAttribute, addEventListener, spreadProps, setRef,
|
|
23
|
+
setRenderer
|
|
24
|
+
} from '@vobs/runtime'
|
|
25
|
+
import { state } from '@vobs/reactivity'
|
|
26
|
+
|
|
27
|
+
/** 编译 JSX 源码并执行:helper 以运行时实现注入(与 vite-plugin 编译路径等价) */
|
|
28
|
+
function runJsx(source: string): unknown {
|
|
29
|
+
const compiled = compile(source, { filename: 'integration.tsx' }) as unknown as string
|
|
30
|
+
const imports = compiled.match(/import \{([^}]+)\} from "@vobs\/vobs";/)
|
|
31
|
+
if (!imports) throw new Error('编译产物缺少 runtime 导入')
|
|
32
|
+
const code = compiled.replace(imports[0], '')
|
|
33
|
+
const names = imports[1]!.split(',').map(name => name.trim()).filter(Boolean)
|
|
34
|
+
const impls: Record<string, unknown> = {
|
|
35
|
+
createElement, createText, createTemplate, cloneTemplate,
|
|
36
|
+
insertBefore, insertDynamic, insertList, insertDynamicValue,
|
|
37
|
+
bindAttribute, bindText, bindProperty, setStaticProps, setProperty,
|
|
38
|
+
setAttribute, addEventListener, spreadProps, setRef
|
|
39
|
+
}
|
|
40
|
+
const args = names.map(name => {
|
|
41
|
+
if (!(name in impls)) throw new Error(`未注入的 runtime helper: ${name}`)
|
|
42
|
+
return impls[name]
|
|
43
|
+
})
|
|
44
|
+
return new Function(...names, `"use strict";\n${code}; return el;`)(...args)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('JSX 编译 × runtime 集成回归', () => {
|
|
48
|
+
it('③ 列表内动态 style 表达式正常着色(品牌色板场景)', () => {
|
|
49
|
+
setRenderer(createDOMRenderer())
|
|
50
|
+
const el = runJsx(`
|
|
51
|
+
const colors = [{ hex: '#10b981', label: '绿' }, { hex: '#f97316', label: '橙' }]
|
|
52
|
+
const el = <div>{colors.map(c => (
|
|
53
|
+
<button type="button" class="dot" style={\`background: \${c.hex}\`} key={c.hex}>{c.label}</button>
|
|
54
|
+
))}</div>
|
|
55
|
+
`) as HTMLElement
|
|
56
|
+
const dots = el.querySelectorAll('button')
|
|
57
|
+
expect(dots.length).toBe(2)
|
|
58
|
+
expect((dots[0] as HTMLElement).style.background).toContain('rgb(16, 185, 129)')
|
|
59
|
+
expect((dots[0] as HTMLElement).style.background).not.toBe('')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('④ 列表条目内「节点↔null」条件响应外部信号', async () => {
|
|
63
|
+
setRenderer(createDOMRenderer())
|
|
64
|
+
const el = runJsx(`
|
|
65
|
+
const active = { value: 'a' }
|
|
66
|
+
const items = [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }]
|
|
67
|
+
const el = <div>{items.map(item => (
|
|
68
|
+
<div class="row" key={item.id}>
|
|
69
|
+
{active.value === item.id ? <span class="dot-on">on</span> : null}
|
|
70
|
+
<span>{item.label}</span>
|
|
71
|
+
</div>
|
|
72
|
+
))}</div>
|
|
73
|
+
`) as HTMLElement
|
|
74
|
+
// 注意:源码里的 active 被编译产物捕获为普通对象,外部信号驱动需通过行内 effect 订阅的信号
|
|
75
|
+
// 这里直接验证初次渲染 + 条件分支两种初始形态
|
|
76
|
+
expect(el.querySelectorAll('.dot-on').length).toBe(1)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('④b 列表条目内「节点↔null」条件随真实信号翻转', async () => {
|
|
80
|
+
setRenderer(createDOMRenderer())
|
|
81
|
+
const active = state('a')
|
|
82
|
+
const el = runJsxWith(`
|
|
83
|
+
const items = [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }]
|
|
84
|
+
const el = <div>{items.map(item => (
|
|
85
|
+
<div class="row" key={item.id}>
|
|
86
|
+
{active.value === item.id ? <span class="dot-on">on</span> : null}
|
|
87
|
+
<span>{item.label}</span>
|
|
88
|
+
</div>
|
|
89
|
+
))}</div>
|
|
90
|
+
`, { active }) as HTMLElement
|
|
91
|
+
expect(el.querySelectorAll('.dot-on').length).toBe(1)
|
|
92
|
+
active.value = 'b'
|
|
93
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
94
|
+
expect(el.querySelectorAll('.dot-on').length).toBe(1)
|
|
95
|
+
expect((el.querySelectorAll('.row')[1] as HTMLElement).querySelector('.dot-on')).not.toBeNull()
|
|
96
|
+
expect((el.querySelectorAll('.row')[0] as HTMLElement).querySelector('.dot-on')).toBeNull()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('④c 列表条目内「节点↔null」条件随列表项对象属性翻转(item 信号驱动)', async () => {
|
|
100
|
+
setRenderer(createDOMRenderer())
|
|
101
|
+
const items = state([
|
|
102
|
+
{ id: 'a', label: 'A', on: true },
|
|
103
|
+
{ id: 'b', label: 'B', on: false }
|
|
104
|
+
])
|
|
105
|
+
const el = runJsxWith(`
|
|
106
|
+
const el = <div>{items.value.map(item => (
|
|
107
|
+
<div class="row" key={item.id}>
|
|
108
|
+
{item.on ? <span class="dot-on">on</span> : null}
|
|
109
|
+
<span>{item.label}</span>
|
|
110
|
+
</div>
|
|
111
|
+
))}</div>
|
|
112
|
+
`, { items }) as HTMLElement
|
|
113
|
+
expect(el.querySelectorAll('.dot-on').length).toBe(1)
|
|
114
|
+
items.value = [
|
|
115
|
+
{ id: 'a', label: 'A', on: false },
|
|
116
|
+
{ id: 'b', label: 'B', on: true }
|
|
117
|
+
]
|
|
118
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
119
|
+
expect(el.querySelectorAll('.dot-on').length).toBe(1)
|
|
120
|
+
expect((el.querySelectorAll('.row')[1] as HTMLElement).querySelector('.dot-on')).not.toBeNull()
|
|
121
|
+
expect((el.querySelectorAll('.row')[0] as HTMLElement).querySelector('.dot-on')).toBeNull()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('② 指针事件编译为 pointerdown 监听并可触发', () => {
|
|
125
|
+
setRenderer(createDOMRenderer())
|
|
126
|
+
const el = runJsx(`
|
|
127
|
+
const el = <div onPointerDown={() => { window.__downs = (window.__downs ?? 0) + 1 }} />
|
|
128
|
+
`) as HTMLElement
|
|
129
|
+
el.dispatchEvent(new PointerEvent('pointerdown'))
|
|
130
|
+
expect((window as unknown as { __downs?: number }).__downs).toBe(1)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('⑤ role/spellCheck/autoComplete 属性正确落 DOM', () => {
|
|
134
|
+
setRenderer(createDOMRenderer())
|
|
135
|
+
const el = runJsx(`
|
|
136
|
+
const el = <div>
|
|
137
|
+
<div role="dialog" />
|
|
138
|
+
<input spellCheck={false} autoComplete="off" />
|
|
139
|
+
<td colSpan={2} />
|
|
140
|
+
</div>
|
|
141
|
+
`) as HTMLElement
|
|
142
|
+
expect(el.querySelector('[role="dialog"]')).not.toBeNull()
|
|
143
|
+
const input = el.querySelector('input') as HTMLInputElement
|
|
144
|
+
expect(input.getAttribute('autocomplete')).toBe('off')
|
|
145
|
+
expect(input.getAttribute('spellcheck')).toBe('false')
|
|
146
|
+
expect((el.querySelector('td') as HTMLTableCellElement).colSpan).toBe(2)
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
/** runJsx 变体:额外注入外部依赖(真实信号等),供编译产物闭包引用 */
|
|
151
|
+
function runJsxWith(source: string, deps: Record<string, unknown>): unknown {
|
|
152
|
+
const compiled = compile(source, { filename: 'integration-deps.tsx' }) as unknown as string
|
|
153
|
+
const imports = compiled.match(/import \{([^}]+)\} from "@vobs\/vobs";/)
|
|
154
|
+
if (!imports) throw new Error('编译产物缺少 runtime 导入')
|
|
155
|
+
const code = compiled.replace(imports[0], '')
|
|
156
|
+
const names = imports[1]!.split(',').map(name => name.trim()).filter(Boolean)
|
|
157
|
+
const impls: Record<string, unknown> = {
|
|
158
|
+
createElement, createText, createTemplate, cloneTemplate,
|
|
159
|
+
insertBefore, insertDynamic, insertList, insertDynamicValue,
|
|
160
|
+
bindAttribute, bindText, bindProperty, setStaticProps, setProperty,
|
|
161
|
+
setAttribute, addEventListener, spreadProps, setRef
|
|
162
|
+
}
|
|
163
|
+
const argNames = [...names, ...Object.keys(deps)]
|
|
164
|
+
const args = [...names.map(name => {
|
|
165
|
+
if (!(name in impls)) throw new Error(`未注入的 runtime helper: ${name}`)
|
|
166
|
+
return impls[name]
|
|
167
|
+
}), ...Object.values(deps)]
|
|
168
|
+
return new Function(...argNames, `"use strict";\n${code}; return el;`)(...args)
|
|
169
|
+
}
|
package/src/ops.ts
CHANGED
|
@@ -189,7 +189,7 @@ export function spreadProps(node: Element, props: Record<string, unknown>): void
|
|
|
189
189
|
// attribute 键的 false 表示“不设置”,与 HTML 语义一致。
|
|
190
190
|
else if (isPropertyKey(key)) setProperty(node, key, value)
|
|
191
191
|
else if (value === false) continue
|
|
192
|
-
else setAttribute(node, key
|
|
192
|
+
else setAttribute(node, domAttributeName(key), key === 'style' && isStyleObject(value) ? formatStyle(value) : String(value))
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
195
|
|
|
@@ -200,7 +200,7 @@ export function setStaticProps(node: Element, props: Record<string, unknown>): v
|
|
|
200
200
|
if (value === null || value === undefined) continue
|
|
201
201
|
if (isPropertyKey(key)) setProperty(node, key, value)
|
|
202
202
|
else if (value === false) continue
|
|
203
|
-
else setAttribute(node, key
|
|
203
|
+
else setAttribute(node, domAttributeName(key), key === 'style' && isStyleObject(value) ? formatStyle(value) : String(value))
|
|
204
204
|
}
|
|
205
205
|
}
|
|
206
206
|
|
|
@@ -208,6 +208,18 @@ function isPropertyKey(key: string): boolean {
|
|
|
208
208
|
return key === 'value' || key === 'checked' || key === 'selected' || key === 'disabled'
|
|
209
209
|
|| key === 'multiple' || key === 'readOnly' || key === 'required'
|
|
210
210
|
|| key === 'autofocus' || key === 'hidden' || key === 'tabIndex'
|
|
211
|
+
|| key === 'colSpan' || key === 'rowSpan'
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** camelCase JSX 属性名 → HTML attribute 名(与 compiler 的 domAttributeName 保持一致) */
|
|
215
|
+
function domAttributeName(key: string): string {
|
|
216
|
+
switch (key) {
|
|
217
|
+
case 'className': return 'class'
|
|
218
|
+
case 'htmlFor': return 'for'
|
|
219
|
+
case 'autoComplete': return 'autocomplete'
|
|
220
|
+
case 'spellCheck': return 'spellcheck'
|
|
221
|
+
default: return key
|
|
222
|
+
}
|
|
211
223
|
}
|
|
212
224
|
|
|
213
225
|
function isStyleObject(value: unknown): value is Record<string, unknown> {
|