@vobs/runtime 1.4.1 → 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.
@@ -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 === 'className' ? 'class' : key, key === 'style' && isStyleObject(value) ? formatStyle(value) : String(value))
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 === 'className' ? 'class' : key, key === 'style' && isStyleObject(value) ? formatStyle(value) : String(value))
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> {