@vobs/runtime 1.4.2 → 1.5.1

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/src/bind.ts CHANGED
@@ -1,15 +1,15 @@
1
- // 动态绑定:将 Signal 绑定到 DOM 节点
2
-
1
+ // 动态绑定:将 Signal 绑定到 DOM 节点
2
+
3
3
  import { effect } from '@vobs/reactivity'
4
4
  import type { Signal } from '@vobs/reactivity'
5
- import { setAttribute, setProperty, setTextContent } from './ops'
5
+ import { registerSelectValueBinding, setAttribute, setProperty, setTextContent } from './ops'
6
6
 
7
7
  export type ValueSource<T> = Signal<T> | (() => T)
8
8
 
9
9
  function readSource<T>(source: ValueSource<T>): T {
10
10
  return typeof source === 'function' ? source() : source.value
11
11
  }
12
-
12
+
13
13
  export function bindText(
14
14
  node: Text,
15
15
  source: ValueSource<unknown>
@@ -19,10 +19,10 @@ export function bindText(
19
19
  setTextContent(node, value === null || value === undefined || typeof value === 'boolean' ? '' : String(value))
20
20
  })
21
21
  }
22
-
22
+
23
23
  export function bindAttribute(
24
- node: Element,
25
- key: string,
24
+ node: Element,
25
+ key: string,
26
26
  source: ValueSource<unknown>
27
27
  ): void {
28
28
  effect(() => {
@@ -41,6 +41,11 @@ export function bindProperty(
41
41
  key: string,
42
42
  source: ValueSource<unknown>
43
43
  ): void {
44
+ // Select initial-value auto resync: remember the bound reader so that inserting
45
+ // <option> children later re-applies the current value (see ops.insertBefore).
46
+ if (key === 'value' && node.nodeName === 'SELECT') {
47
+ registerSelectValueBinding(node, () => readSource(source))
48
+ }
44
49
  effect(() => {
45
50
  setProperty(node, key, readSource(source))
46
51
  })
@@ -0,0 +1,198 @@
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
+ it('⑥ select 绑定 value 时选项异步到达自动重同步(不再需要 ref 兜底)', async () => {
150
+ setRenderer(createDOMRenderer())
151
+ const items = state<string[]>([])
152
+ const el = runJsxWith(`
153
+ const current = { value: 'b' }
154
+ const el = <select value={current.value}>{items.value.map(o => <option value={o}>{o}</option>)}</select>
155
+ `, { items }) as HTMLSelectElement
156
+ expect(el.value).toBe('')
157
+ items.value = ['a', 'b', 'c']
158
+ await new Promise(resolve => setTimeout(resolve, 20))
159
+ expect(el.value).toBe('b')
160
+ })
161
+
162
+ it('⑥b select 选项经 optgroup 插入同样重同步', async () => {
163
+ setRenderer(createDOMRenderer())
164
+ interface Group { readonly label: string; readonly options: readonly string[] }
165
+ const groups = state<readonly Group[]>([])
166
+ const el = runJsxWith(`
167
+ const current = { value: 'b' }
168
+ const el = <select value={current.value}>{groups.value.map(group => (
169
+ <optgroup label={group.label}>{group.options.map(o => <option value={o}>{o}</option>)}</optgroup>
170
+ ))}</select>
171
+ `, { groups }) as HTMLSelectElement
172
+ expect(el.value).toBe('')
173
+ groups.value = [{ label: 'G', options: ['a', 'b'] }]
174
+ await new Promise(resolve => setTimeout(resolve, 20))
175
+ expect(el.value).toBe('b')
176
+ })
177
+ })
178
+
179
+ /** runJsx 变体:额外注入外部依赖(真实信号等),供编译产物闭包引用 */
180
+ function runJsxWith(source: string, deps: Record<string, unknown> = {}): unknown {
181
+ const compiled = compile(source, { filename: 'integration-deps.tsx' }) as unknown as string
182
+ const imports = compiled.match(/import \{([^}]+)\} from "@vobs\/vobs";/)
183
+ if (!imports) throw new Error('编译产物缺少 runtime 导入')
184
+ const code = compiled.replace(imports[0], '')
185
+ const names = imports[1]!.split(',').map(name => name.trim()).filter(Boolean)
186
+ const impls: Record<string, unknown> = {
187
+ createElement, createText, createTemplate, cloneTemplate,
188
+ insertBefore, insertDynamic, insertList, insertDynamicValue,
189
+ bindAttribute, bindText, bindProperty, setStaticProps, setProperty,
190
+ setAttribute, addEventListener, spreadProps, setRef
191
+ }
192
+ const argNames = [...names, ...Object.keys(deps)]
193
+ const args = [...names.map(name => {
194
+ if (!(name in impls)) throw new Error(`未注入的 runtime helper: ${name}`)
195
+ return impls[name]
196
+ }), ...Object.values(deps)]
197
+ return new Function(...argNames, `"use strict";\n${code}; return el;`)(...args)
198
+ }
package/src/ops.ts CHANGED
@@ -64,6 +64,8 @@ export function insertBefore(
64
64
  }
65
65
  getRenderer().insertBefore(parent, child, isVobsFragment(anchor) ? anchor.start : anchor)
66
66
  markHmrInstanceMounted(child, parent)
67
+ const nodeName = (child as Element).nodeName
68
+ if (nodeName === 'OPTION' || nodeName === 'OPTGROUP') syncSelectValue(parent)
67
69
  if (getRuntimeDebugHooks()) {
68
70
  invokeRuntimeDebug('domMutation', {
69
71
  operation: 'insert',
@@ -73,6 +75,31 @@ export function insertBefore(
73
75
  }
74
76
  }
75
77
 
78
+ /**
79
+ * Select initial-value auto resync: `bindProperty(node, 'value', …)` registers the value
80
+ * reader for `<select>` elements; whenever an `<option>` is inserted into the select
81
+ * (directly or through an `<optgroup>`), the current bound value is re-applied. This
82
+ * removes the last reason for apps to keep `ref + queueMicrotask` value-sync workarounds
83
+ * for options that arrive after the value binding (e.g. asynchronously loaded lists).
84
+ */
85
+ const selectValueReaders = new WeakMap<object, () => unknown>()
86
+
87
+ export function registerSelectValueBinding(select: Element, read: () => unknown): void {
88
+ selectValueReaders.set(select, read)
89
+ }
90
+
91
+ function syncSelectValue(parent: Node): void {
92
+ let current: Node | null = parent
93
+ while (current !== null) {
94
+ if ((current as Element).nodeName === 'SELECT') {
95
+ const read = selectValueReaders.get(current)
96
+ if (read !== undefined) setProperty(current as Element, 'value', read())
97
+ return
98
+ }
99
+ current = current.parentNode
100
+ }
101
+ }
102
+
76
103
  export function removeChild(
77
104
  parent: Node,
78
105
  child: VobsNode
@@ -189,7 +216,7 @@ export function spreadProps(node: Element, props: Record<string, unknown>): void
189
216
  // attribute 键的 false 表示“不设置”,与 HTML 语义一致。
190
217
  else if (isPropertyKey(key)) setProperty(node, key, value)
191
218
  else if (value === false) continue
192
- else setAttribute(node, key === 'className' ? 'class' : key, key === 'style' && isStyleObject(value) ? formatStyle(value) : String(value))
219
+ else setAttribute(node, domAttributeName(key), key === 'style' && isStyleObject(value) ? formatStyle(value) : String(value))
193
220
  }
194
221
  }
195
222
 
@@ -200,7 +227,7 @@ export function setStaticProps(node: Element, props: Record<string, unknown>): v
200
227
  if (value === null || value === undefined) continue
201
228
  if (isPropertyKey(key)) setProperty(node, key, value)
202
229
  else if (value === false) continue
203
- else setAttribute(node, key === 'className' ? 'class' : key, key === 'style' && isStyleObject(value) ? formatStyle(value) : String(value))
230
+ else setAttribute(node, domAttributeName(key), key === 'style' && isStyleObject(value) ? formatStyle(value) : String(value))
204
231
  }
205
232
  }
206
233
 
@@ -208,6 +235,18 @@ function isPropertyKey(key: string): boolean {
208
235
  return key === 'value' || key === 'checked' || key === 'selected' || key === 'disabled'
209
236
  || key === 'multiple' || key === 'readOnly' || key === 'required'
210
237
  || key === 'autofocus' || key === 'hidden' || key === 'tabIndex'
238
+ || key === 'colSpan' || key === 'rowSpan'
239
+ }
240
+
241
+ /** camelCase JSX 属性名 → HTML attribute 名(与 compiler 的 domAttributeName 保持一致) */
242
+ function domAttributeName(key: string): string {
243
+ switch (key) {
244
+ case 'className': return 'class'
245
+ case 'htmlFor': return 'for'
246
+ case 'autoComplete': return 'autocomplete'
247
+ case 'spellCheck': return 'spellcheck'
248
+ default: return key
249
+ }
211
250
  }
212
251
 
213
252
  function isStyleObject(value: unknown): value is Record<string, unknown> {