@vobs/runtime 1.7.8 → 1.8.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.
Files changed (127) hide show
  1. package/README.md +1 -1
  2. package/dist/async-boundary.cjs +14 -4
  3. package/dist/async-boundary.cjs.map +1 -1
  4. package/dist/async-boundary.js +17 -7
  5. package/dist/async-boundary.js.map +1 -1
  6. package/dist/bind.cjs +43 -3
  7. package/dist/bind.cjs.map +1 -1
  8. package/dist/bind.js +48 -8
  9. package/dist/bind.js.map +1 -1
  10. package/dist/boundary.cjs +159 -84
  11. package/dist/boundary.cjs.map +1 -1
  12. package/dist/boundary.js +162 -87
  13. package/dist/boundary.js.map +1 -1
  14. package/dist/client-only.cjs +323 -0
  15. package/dist/client-only.cjs.map +1 -0
  16. package/dist/client-only.d.cts +42 -0
  17. package/dist/client-only.d.ts +42 -0
  18. package/dist/client-only.js +299 -0
  19. package/dist/client-only.js.map +1 -0
  20. package/dist/debug.cjs.map +1 -1
  21. package/dist/debug.d.cts +12 -2
  22. package/dist/debug.d.ts +12 -2
  23. package/dist/debug.js.map +1 -1
  24. package/dist/dom-events.cjs +46 -0
  25. package/dist/dom-events.cjs.map +1 -0
  26. package/dist/dom-events.d.cts +23 -0
  27. package/dist/dom-events.d.ts +23 -0
  28. package/dist/dom-events.js +21 -0
  29. package/dist/dom-events.js.map +1 -0
  30. package/dist/dom-props.cjs +154 -0
  31. package/dist/dom-props.cjs.map +1 -0
  32. package/dist/dom-props.d.cts +21 -0
  33. package/dist/dom-props.d.ts +21 -0
  34. package/dist/dom-props.js +128 -0
  35. package/dist/dom-props.js.map +1 -0
  36. package/dist/dynamic.cjs +68 -14
  37. package/dist/dynamic.cjs.map +1 -1
  38. package/dist/dynamic.js +73 -19
  39. package/dist/dynamic.js.map +1 -1
  40. package/dist/error-boundary.cjs +159 -84
  41. package/dist/error-boundary.cjs.map +1 -1
  42. package/dist/error-boundary.js +162 -87
  43. package/dist/error-boundary.js.map +1 -1
  44. package/dist/error.cjs +52 -20
  45. package/dist/error.cjs.map +1 -1
  46. package/dist/error.d.cts +8 -0
  47. package/dist/error.d.ts +8 -0
  48. package/dist/error.js +52 -20
  49. package/dist/error.js.map +1 -1
  50. package/dist/fragment.cjs.map +1 -1
  51. package/dist/fragment.js +1 -1
  52. package/dist/fragment.js.map +1 -1
  53. package/dist/hmr.cjs +137 -1
  54. package/dist/hmr.cjs.map +1 -1
  55. package/dist/hmr.js +135 -1
  56. package/dist/hmr.js.map +1 -1
  57. package/dist/index.cjs +713 -236
  58. package/dist/index.cjs.map +1 -1
  59. package/dist/index.d.cts +5 -2
  60. package/dist/index.d.ts +5 -2
  61. package/dist/index.js +715 -245
  62. package/dist/index.js.map +1 -1
  63. package/dist/ops.cjs +561 -107
  64. package/dist/ops.cjs.map +1 -1
  65. package/dist/ops.d.cts +49 -4
  66. package/dist/ops.d.ts +49 -4
  67. package/dist/ops.js +557 -108
  68. package/dist/ops.js.map +1 -1
  69. package/dist/profiler.cjs +14 -4
  70. package/dist/profiler.cjs.map +1 -1
  71. package/dist/profiler.js +17 -7
  72. package/dist/profiler.js.map +1 -1
  73. package/dist/ref.cjs +160 -5
  74. package/dist/ref.cjs.map +1 -1
  75. package/dist/ref.js +160 -5
  76. package/dist/ref.js.map +1 -1
  77. package/dist/renderer.cjs.map +1 -1
  78. package/dist/renderer.d.cts +2 -0
  79. package/dist/renderer.d.ts +2 -0
  80. package/dist/show.cjs +196 -0
  81. package/dist/show.cjs.map +1 -0
  82. package/dist/show.d.cts +54 -0
  83. package/dist/show.d.ts +54 -0
  84. package/dist/show.js +171 -0
  85. package/dist/show.js.map +1 -0
  86. package/dist/svg.cjs +102 -0
  87. package/dist/svg.cjs.map +1 -0
  88. package/dist/svg.d.cts +21 -0
  89. package/dist/svg.d.ts +21 -0
  90. package/dist/svg.js +76 -0
  91. package/dist/svg.js.map +1 -0
  92. package/package.json +20 -2
  93. package/src/bind.ts +30 -2
  94. package/src/boundaries.test.ts +54 -1
  95. package/src/boundary.ts +24 -8
  96. package/src/clear-remove.test.ts +85 -0
  97. package/src/client-only.test.ts +140 -0
  98. package/src/client-only.ts +65 -0
  99. package/src/component-empty-return.test.ts +123 -0
  100. package/src/debug.ts +12 -1
  101. package/src/dom-events.ts +32 -0
  102. package/src/dom-props-consistency.test.ts +149 -0
  103. package/src/dom-props.test.ts +70 -0
  104. package/src/dom-props.ts +107 -0
  105. package/src/dynamic.ts +91 -14
  106. package/src/error.test.ts +76 -1
  107. package/src/error.ts +87 -22
  108. package/src/event-cleanup-growth.test.ts +128 -0
  109. package/src/events.test.ts +16 -3
  110. package/src/hmr.ts +8 -2
  111. package/src/index.ts +13 -1
  112. package/src/invariants.test.ts +252 -0
  113. package/src/list-fast-path.test.ts +83 -0
  114. package/src/list-reactive-item.test.ts +77 -0
  115. package/src/ops.ts +419 -60
  116. package/src/props.test.ts +61 -1
  117. package/src/ref-cleanup.test.ts +144 -0
  118. package/src/ref.ts +57 -8
  119. package/src/remove-listener-identity.test.ts +97 -0
  120. package/src/renderer.ts +3 -1
  121. package/src/select-hidden-subscription.test.ts +104 -0
  122. package/src/show-classlist.test.ts +205 -0
  123. package/src/show.ts +107 -0
  124. package/src/spread-stable-shape.test.ts +109 -0
  125. package/src/spread.test.ts +86 -0
  126. package/src/svg.test.ts +28 -1
  127. package/src/svg.ts +34 -0
@@ -0,0 +1,123 @@
1
+ // @vitest-environment jsdom
2
+ /*
3
+ * 组件返回 `null` / `undefined` / `false` **不得让 App 崩溃**。
4
+ *
5
+ * 这是生产事故的根因(Labelune 2026-09-30 发版黑屏):
6
+ * `createComponent` 把渲染结果直接当作 `nodeOwners` 这个 **WeakMap 的 key**,
7
+ * 而 `null` / `undefined` 不是合法 key → 抛 `Invalid value used as weak map key`。
8
+ * 最贵的地方是**诊断信息**:App 挂载即崩、没有任何 JS 崩溃日志、splash 兜底也失效。
9
+ *
10
+ * 「组件顶层条件 return null」(`cond ? <X/> : null`、`cond && <X/>`)是合法 JSX 写法。
11
+ * 修法:归一化成**空注释节点** —— 视觉为空(与返回 null 一致),但满足 `VobsNode` 契约,
12
+ * 于是挂载 / HMR 刷新 / 节点替换 / 水合都不需要各自特判"空"分支。
13
+ */
14
+ import { describe, expect, it } from 'vitest'
15
+ import { scheduler, state } from '@vobs/reactivity'
16
+ import { createComponent, createDOMRenderer, createText, createVobs, setRenderer } from '@vobs/vobs'
17
+ import type { VobsNode } from '@vobs/vobs'
18
+ import { insertDynamic } from './index'
19
+
20
+ setRenderer(createDOMRenderer())
21
+ const settle = (): void => { scheduler.flush() }
22
+
23
+ function mount(render: () => VobsNode) {
24
+ const host = document.createElement('main')
25
+ document.body.appendChild(host)
26
+ const app = createVobs({ render })
27
+ app.mount(host)
28
+ return { host, app, cleanup: () => { app.destroy(); host.remove() } }
29
+ }
30
+
31
+ describe('组件返回空值不再崩溃', () => {
32
+ it('顶层 return null → 不抛、渲染为空', () => {
33
+ const Cond = (props: { on: boolean }): VobsNode =>
34
+ (props.on ? createText('yes') : null) as unknown as VobsNode
35
+ let view: ReturnType<typeof mount> | undefined
36
+ expect(() => { view = mount(() => createComponent(Cond, { on: false })) }).not.toThrow()
37
+ expect(view!.host.textContent).toBe('')
38
+ view!.cleanup()
39
+ })
40
+
41
+ it('返回 undefined → 不抛、渲染为空', () => {
42
+ const Blank = (): VobsNode => undefined as unknown as VobsNode
43
+ let view: ReturnType<typeof mount> | undefined
44
+ expect(() => { view = mount(() => createComponent(Blank, {})) }).not.toThrow()
45
+ expect(view!.host.textContent).toBe('')
46
+ view!.cleanup()
47
+ })
48
+
49
+ it('返回 false(`cond && <X/>` 形态)→ 不抛、渲染为空', () => {
50
+ const Maybe = (props: { on: boolean }): VobsNode =>
51
+ (props.on && createText('yes')) as unknown as VobsNode
52
+ let view: ReturnType<typeof mount> | undefined
53
+ expect(() => { view = mount(() => createComponent(Maybe, { on: false })) }).not.toThrow()
54
+ expect(view!.host.textContent).toBe('')
55
+ view!.cleanup()
56
+ })
57
+
58
+ it('cond 为真时正常渲染(没有把功能一起打死)', () => {
59
+ const Cond = (props: { on: boolean }): VobsNode =>
60
+ (props.on ? createText('yes') : null) as unknown as VobsNode
61
+ const view = mount(() => createComponent(Cond, { on: true }))
62
+ expect(view.host.textContent).toBe('yes')
63
+ view.cleanup()
64
+ })
65
+
66
+ it('返回 null 的组件**嵌在树中间**时,兄弟节点照常渲染', () => {
67
+ const Blank = (): VobsNode => null as unknown as VobsNode
68
+ const view = mount(() => {
69
+ const wrap = document.createElement('div')
70
+ wrap.append(document.createTextNode('before'))
71
+ wrap.appendChild(createComponent(Blank, {}) as unknown as Node)
72
+ wrap.append(document.createTextNode('after'))
73
+ return wrap as unknown as VobsNode
74
+ })
75
+ expect(view.host.textContent).toBe('beforeafter')
76
+ view.cleanup()
77
+ })
78
+
79
+ it('条件工厂在 null ↔ 节点之间切换(响应式)', () => {
80
+ const on = state(false)
81
+ /*
82
+ * 注意这里用的是 `createBlock`(条件工厂)而不是 `createComponent(..., { on: on.value })`。
83
+ * 后者是**违反 run-once 契约**的写法:组件体与渲染只执行一次,
84
+ * 在外层 render 里读信号不会订阅、也不会重跑(正是文档里 B 条那条纪律)。
85
+ * 结构性变化必须由条件工厂或绑定 effect 承担 —— 响应式来自 `insertDynamic`
86
+ * 驱动的工厂,`createBlock` 只是"每次都重新建"的工厂本身。
87
+ */
88
+ const view = mount(() => {
89
+ const wrap = document.createElement('div')
90
+ insertDynamic(wrap as unknown as Node, null, () => (on.value ? createText('shown') : null))
91
+ return wrap as unknown as VobsNode
92
+ })
93
+
94
+ expect(view.host.textContent).toBe('')
95
+ on.value = true
96
+ settle()
97
+ expect(view.host.textContent, '切到节点后没有渲染出来').toBe('shown')
98
+ on.value = false
99
+ settle()
100
+ expect(view.host.textContent, '切回 null 后没有清空').toBe('')
101
+ view.cleanup()
102
+ })
103
+
104
+ it('返回 null 的组件销毁时不抛错(Owner 正常释放)', () => {
105
+ const Blank = (): VobsNode => null as unknown as VobsNode
106
+ const view = mount(() => createComponent(Blank, {}))
107
+ expect(() => view.app.destroy()).not.toThrow()
108
+ view.host.remove()
109
+ })
110
+
111
+ it('连续多个返回 null 的组件并存', () => {
112
+ const Blank = (): VobsNode => null as unknown as VobsNode
113
+ const view = mount(() => {
114
+ const wrap = document.createElement('div')
115
+ wrap.appendChild(createComponent(Blank, {}) as unknown as Node)
116
+ wrap.appendChild(createComponent(Blank, {}) as unknown as Node)
117
+ wrap.appendChild(createComponent(Blank, {}) as unknown as Node)
118
+ return wrap as unknown as VobsNode
119
+ })
120
+ expect(view.host.textContent).toBe('')
121
+ expect(() => view.cleanup()).not.toThrow()
122
+ })
123
+ })
package/src/debug.ts CHANGED
@@ -26,7 +26,7 @@ export interface RuntimeHydrationMismatch {
26
26
  readonly message: string
27
27
  }
28
28
 
29
- export type RuntimeDomMutationOperation = 'text' | 'property' | 'attribute' | 'insert' | 'remove'
29
+ export type RuntimeDomMutationOperation = 'text' | 'property' | 'attribute' | 'insert' | 'remove' | 'clear'
30
30
 
31
31
  export interface RuntimeDomMutation {
32
32
  readonly operation: RuntimeDomMutationOperation
@@ -49,6 +49,17 @@ export interface RuntimeDebugHooks {
49
49
  domMutation?(mutation: RuntimeDomMutation): void
50
50
  error?(event: RuntimeErrorEvent): void
51
51
  hydrationMismatch?(event: RuntimeHydrationMismatch): void
52
+ /**
53
+ * 水合时**临时认领**了一个内容不为空的文本节点(服务端渲染了真实文本,客户端此刻要的是空文本:
54
+ * 只能先认领、等绑定 effect 覆写)。这一步过去完全无声 —— 服务端 "Ada" 遇上客户端忘传 state
55
+ * (会渲染 "loading")时就静默变成了后者。给出可观测出口,便于工具发现这类"值被悄悄换掉"。
56
+ */
57
+ hydrationProvisionalText?(event: RuntimeProvisionalText): void
58
+ }
59
+
60
+ export interface RuntimeProvisionalText {
61
+ readonly expected: string
62
+ readonly actual: string
52
63
  }
53
64
 
54
65
  let activeRuntimeDebugHooks: RuntimeDebugHooks | null = null
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `on*` 属性名 → 真实 DOM 事件名。
3
+ *
4
+ * 与 `dom-props.ts` 的属性名表同理:这必须是**单一来源**。
5
+ * 此前编译期与运行期各写一份、且不一致:
6
+ * - 编译期(`@vobs/compiler` 的 `dom-events.ts`)有别名表,知道 `onDoubleClick` → `dblclick`;
7
+ * - 运行期的 `{...props}` 展开路径只做 `name.slice(2).toLowerCase()`
8
+ * → `onDoubleClick` 变成 `"doubleclick"`,**DOM 里根本叫 dblclick**,
9
+ * 于是回调永不触发,而且不报错。
10
+ * 实测(.artifacts/reports/runtime.supplement.md 缺点 2):派发 `dblclick` 命中 0 次,
11
+ * 隐藏的 `doubleclick` 监听命中 1 次。同一个包在 `dom-props.ts:1-8` 自称"两边逐字重复、
12
+ * 已收拢成单一来源",但那只覆盖属性名,没覆盖事件名。
13
+ */
14
+
15
+ /** JSX 驼峰名与 DOM 事件名不一致的少数情况(小写后的驼峰名 → 真实事件名)。 */
16
+ export const EVENT_NAME_ALIASES: Readonly<Record<string, string>> = {
17
+ // React 风格的 onDoubleClick 对应的 DOM 事件是 dblclick
18
+ doubleclick: 'dblclick',
19
+ // 少数人按 addEventListener 的写法写成 onDblClick
20
+ dblclick: 'dblclick'
21
+ }
22
+
23
+ /**
24
+ * 把 `on*` 属性名解析成真实事件名。
25
+ *
26
+ * @returns 名字不以 `on` 开头或只有 `on` 本身时返回 undefined。
27
+ */
28
+ export function resolveEventName(attributeName: string): string | undefined {
29
+ if (!attributeName.startsWith('on') || attributeName.length <= 2) return undefined
30
+ const lowered = attributeName.slice(2).toLowerCase()
31
+ return EVENT_NAME_ALIASES[lowered] ?? lowered
32
+ }
@@ -0,0 +1,149 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from 'vitest'
3
+ import { compile } from '@vobs/compiler'
4
+ import { createDOMRenderer, setRenderer, state } from '@vobs/vobs'
5
+ import { isPropertyName } from './dom-props'
6
+ import { bindSpreadProps, createElement, setProperty, setStaticProps, spreadProps } from './index'
7
+
8
+ setRenderer(createDOMRenderer())
9
+
10
+ const flush = async () => { await Promise.resolve(); await Promise.resolve() }
11
+
12
+ /*
13
+ * 两条都是"编译期与运行期各写一份、且不一致"的老问题(.artifacts/reports/runtime.supplement.md 缺点 2/3)。
14
+ * `dom-props.ts:1-8` 自称两边已收拢成单一来源,但那只覆盖**属性名**,没覆盖**事件名**,
15
+ * 而属性名表里 `autofocus` 又用 DOM 小写、与同表的 readOnly/tabIndex 约定相反。
16
+ */
17
+ describe('on* 事件名:运行期必须与编译期用同一张别名表', () => {
18
+ it('onDoubleClick 展开后挂的是 dblclick,不是不存在的 doubleclick', async () => {
19
+ const calls: string[] = []
20
+ const props = { onDoubleClick: () => calls.push('dbl') }
21
+ const el = createElement('div')
22
+ spreadProps(el, props)
23
+
24
+ el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
25
+ // 修复前:真实事件名是 "doubleclick",dblclick 命中 0 次(回调永不触发且静默)
26
+ expect(calls).toEqual(['dbl'])
27
+ })
28
+
29
+ it('onDoubleClick 的"隐藏监听"不存在(不会再挂到 doubleclick 上)', () => {
30
+ const calls: string[] = []
31
+ const el = createElement('div')
32
+ spreadProps(el, { onDoubleClick: () => calls.push('dbl') })
33
+ el.dispatchEvent(new Event('doubleclick', { bubbles: true }))
34
+ expect(calls).toEqual([])
35
+ })
36
+
37
+ it('自定义事件 onMyEvent 保留大小写语义(不把 myEvent 降成 myevent 之外的东西)', () => {
38
+ const calls: string[] = []
39
+ const el = createElement('div')
40
+ spreadProps(el, { onMyEvent: () => calls.push('custom') })
41
+ el.dispatchEvent(new Event('myevent', { bubbles: true }))
42
+ expect(calls).toEqual(['custom'])
43
+ })
44
+
45
+ it('身份稳定的展开里替换 onDoubleClick 时,旧监听被摘掉', async () => {
46
+ const calls: string[] = []
47
+ const first = state<() => void>(() => calls.push('first'))
48
+ const props = { get onDoubleClick() { return first.value } }
49
+ const el = createElement('div')
50
+ bindSpreadProps(el, () => props)
51
+ await flush()
52
+
53
+ el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
54
+ expect(calls).toEqual(['first'])
55
+
56
+ first.value = () => calls.push('second')
57
+ await flush()
58
+ el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
59
+ expect(calls).toEqual(['first', 'second'])
60
+ })
61
+
62
+ it('编译产物直接跑:onDoubleClick 真的能用', () => {
63
+ // 注意 compile() 直接返回**代码字符串**(不是 { code })
64
+ const output = compile(
65
+ 'export const A = (props) => <div onDoubleClick={props.hit} />',
66
+ { filename: 'a.tsx' }
67
+ )
68
+ expect(output).toContain('"dblclick"')
69
+ expect(output).not.toContain('"doubleclick"')
70
+ })
71
+ })
72
+
73
+ describe('autoFocus={false} 不得让元素自动聚焦', () => {
74
+ /*
75
+ * 判据是**属性存在与否**,不是 `el.autofocus`:
76
+ * 实测 jsdom 与真实 DOM 的 IDL 都不反射 autofocus(`el.autofocus = true` 是 no-op,
77
+ * 既不建属性也不改值),所以 property 通道在这里是静默失败 —— 修法必须走 attribute 通道。
78
+ * 而 autofocus 的语义是"**属性存在**就聚焦",与属性的值无关,
79
+ * 所以 `String(false)` 写出的 `autofocus="false"` 会让 `autoFocus={false}` **真的自动聚焦**。
80
+ */
81
+ it('autoFocus={false} 不得留下 autofocus 属性', () => {
82
+ const node = document.createElement('input') as HTMLInputElement
83
+ setStaticProps(node, { autoFocus: false })
84
+ // 修复前:走 attribute 通道 → String(false) → autofocus="false" → 仍然自动聚焦
85
+ expect(node.hasAttribute('autofocus')).toBe(false)
86
+ })
87
+
88
+ it('autoFocus 为真时属性存在(属性存在即聚焦)', () => {
89
+ const node = document.createElement('input') as HTMLInputElement
90
+ setStaticProps(node, { autoFocus: true })
91
+ expect(node.hasAttribute('autofocus')).toBe(true)
92
+ })
93
+
94
+ it('autoFocus 走 property 通道,且映射到全小写的 IDL 名', () => {
95
+ /*
96
+ * 这里曾经断言 `domAttributeName('autoFocus') === 'autofocus'`(走 attribute 别名)。
97
+ * 那个做法是**错的**:`autofocus` 是布尔属性,attribute 通道会把 false 序列化成
98
+ * `autofocus="false"`,而"属性存在即聚焦" → `autoFocus={false}` 反而真的聚焦。
99
+ * 正解是走 property 通道(属性随 IDL 反射被正确移除)。
100
+ */
101
+ expect(isPropertyName('autoFocus')).toBe(true)
102
+ // 但 IDL 名是全小写 `autofocus` —— 直接用驼峰 Reflect.set 只会挂 expando(静默无效)
103
+ const el = document.createElement('input') as HTMLInputElement
104
+ setProperty(el, 'autoFocus', true)
105
+ expect(el.hasAttribute('autofocus')).toBe(true)
106
+ expect(el.autofocus).toBe(true)
107
+ setProperty(el, 'autoFocus', false)
108
+ expect(el.hasAttribute('autofocus')).toBe(false)
109
+ expect(el.autofocus).toBe(false)
110
+ })
111
+
112
+ it('展开里值为 false 时不设置属性(初始与增量都算)', async () => {
113
+ const flag = state(false)
114
+ const props = { get autoFocus() { return flag.value } }
115
+ const el = createElement('input')
116
+ bindSpreadProps(el, () => props)
117
+ await flush()
118
+ // 这是缺陷的本体:修复前 `String(false)` 会写出 autofocus="false" → 仍然自动聚焦
119
+ expect(el.hasAttribute('autofocus')).toBe(false)
120
+
121
+ // 真值时属性存在(属性存在即聚焦)
122
+ flag.value = true
123
+ await flush()
124
+ expect(el.hasAttribute('autofocus')).toBe(true)
125
+ })
126
+
127
+ it('展开里删掉 autoFocus 键时属性被移除', async () => {
128
+ const present = state(true)
129
+ const props = new Proxy({} as Record<string, unknown>, {
130
+ ownKeys: () => (present.value ? ['autoFocus'] : []),
131
+ getOwnPropertyDescriptor: () => ({ enumerable: true, configurable: true, value: true }),
132
+ get: (_t, key) => (key === 'autoFocus' ? true : undefined)
133
+ })
134
+ const el = createElement('input')
135
+ bindSpreadProps(el, () => props)
136
+ await flush()
137
+ expect(el.hasAttribute('autofocus')).toBe(true)
138
+
139
+ present.value = false
140
+ await flush()
141
+ expect(el.hasAttribute('autofocus')).toBe(false)
142
+ })
143
+
144
+ it('小写 autofocus 同样走属性通道(老写法不能是另一套语义)', () => {
145
+ const node = document.createElement('input') as HTMLInputElement
146
+ setStaticProps(node, { autofocus: false })
147
+ expect(node.hasAttribute('autofocus')).toBe(false)
148
+ })
149
+ })
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { domAttributeName, isPropertyName } from './dom-props'
3
+
4
+ describe('isPropertyName —— 哪些名字必须走 property 通道', () => {
5
+ it('attribute 形式无效或名字对不上的,走 property', () => {
6
+ // innerHTML / textContent 根本不是 HTML 属性,setAttribute 完全无效
7
+ expect(isPropertyName('innerHTML')).toBe(true)
8
+ expect(isPropertyName('innerText')).toBe(true)
9
+ expect(isPropertyName('textContent')).toBe(true)
10
+ // defaultValue 的 attribute 形式叫 value,名字对不上
11
+ expect(isPropertyName('defaultValue')).toBe(true)
12
+ expect(isPropertyName('defaultChecked')).toBe(true)
13
+ // 这两个没有对应 attribute
14
+ expect(isPropertyName('indeterminate')).toBe(true)
15
+ expect(isPropertyName('currentTime')).toBe(true)
16
+ })
17
+
18
+ it('表单状态与常见 property', () => {
19
+ for (const name of ['value', 'checked', 'selected', 'disabled', 'multiple', 'readOnly', 'required', 'tabIndex', 'colSpan']) {
20
+ expect(isPropertyName(name)).toBe(true)
21
+ }
22
+ })
23
+
24
+ it('attribute 与 data-*/aria-* 不受影响', () => {
25
+ for (const name of ['className', 'title', 'id', 'data-id', 'aria-label', 'strokeWidth', 'someProp']) {
26
+ expect(isPropertyName(name)).toBe(false)
27
+ }
28
+ })
29
+ })
30
+
31
+ describe('domAttributeName —— JSX 属性名 → DOM attribute 名', () => {
32
+ it('HTML 别名', () => {
33
+ expect(domAttributeName('className')).toBe('class')
34
+ expect(domAttributeName('htmlFor')).toBe('for')
35
+ expect(domAttributeName('autoComplete')).toBe('autocomplete')
36
+ expect(domAttributeName('spellCheck')).toBe('spellcheck')
37
+ })
38
+
39
+ it('SVG 表现属性转短横线(写驼峰会静默无效)', () => {
40
+ expect(domAttributeName('strokeWidth')).toBe('stroke-width')
41
+ expect(domAttributeName('strokeLinecap')).toBe('stroke-linecap')
42
+ expect(domAttributeName('fillOpacity')).toBe('fill-opacity')
43
+ expect(domAttributeName('textAnchor')).toBe('text-anchor')
44
+ expect(domAttributeName('stopColor')).toBe('stop-color')
45
+ expect(domAttributeName('pointerEvents')).toBe('pointer-events')
46
+ expect(domAttributeName('markerStart')).toBe('marker-start')
47
+ })
48
+
49
+ /*
50
+ * 回归:SVG 的属性命名是混合的 —— 表现属性是短横线,结构属性却真是驼峰。
51
+ * 曾经按「驼峰一律转短横线」实现过,会把下面这些改坏(viewBox → view-box 等),
52
+ * 而改坏之后是完全静默的:浏览器不认识的属性,图形照旧不渲染,没有任何报错。
53
+ */
54
+ it('SVG 结构属性必须保持驼峰', () => {
55
+ for (const name of [
56
+ 'viewBox', 'preserveAspectRatio', 'pathLength', 'startOffset', 'textLength', 'lengthAdjust',
57
+ 'markerWidth', 'markerHeight', 'markerUnits', 'clipPathUnits', 'gradientUnits',
58
+ 'patternUnits', 'maskUnits', 'spreadMethod', 'zoomAndPan', 'baseFrequency', 'numOctaves',
59
+ 'attributeName', 'calcMode', 'keyTimes', 'repeatCount', 'refX', 'refY'
60
+ ]) {
61
+ expect(domAttributeName(name), name).toBe(name)
62
+ }
63
+ })
64
+
65
+ it('不认识的名字原样透传', () => {
66
+ expect(domAttributeName('data-id')).toBe('data-id')
67
+ expect(domAttributeName('aria-label')).toBe('aria-label')
68
+ expect(domAttributeName('someProp')).toBe('someProp')
69
+ })
70
+ })
@@ -0,0 +1,107 @@
1
+ /**
2
+ * JSX 属性名 → DOM 设置方式的**单一来源**。
3
+ *
4
+ * compiler 在编译期用它决定生成 setProperty/bindProperty 还是 setAttribute/bindAttribute,
5
+ * runtime 在展开 `{...props}` 时用同一套判断。此前两边各写了一份**逐字重复**的实现
6
+ * (compile.ts 与 ops.ts),注释还写着「保持一致」—— 分叉即静默改道,所以收到这里。
7
+ *
8
+ * 这张表只描述「名字」,不占用户包体:compiler 侧是构建期用,runtime 侧是几十个字符串。
9
+ */
10
+
11
+ /**
12
+ * 走 property 通道的名字(`el[name] = value`)。
13
+ *
14
+ * 判断标准是「当成 attribute 设不管用、或者语义不同」:
15
+ * - `innerHTML` / `textContent` 不是 HTML 属性,setAttribute 完全无效(静默无操作)
16
+ * - `defaultValue` / `defaultChecked` 的 attribute 形式是 value/checked,名字对不上
17
+ * - `indeterminate` / `currentTime` / `volume` 等根本没有对应 attribute
18
+ * 其余常见名字(value/checked/disabled/…)两条通道都能工作,列在这里是为了语义正确
19
+ * (如 `disabled={false}` 必须清除 property)。
20
+ */
21
+ const PROPERTY_NAMES: ReadonlySet<string> = new Set([
22
+ // 表单状态
23
+ 'value', 'checked', 'selected', 'disabled', 'multiple', 'readOnly', 'required',
24
+ 'defaultValue', 'defaultChecked', 'indeterminate',
25
+ /*
26
+ * `autoFocus` 用**驼峰**(与同表 readOnly/tabIndex/defaultChecked 一致),且必须走
27
+ * **property 通道**:`autofocus` 是**布尔属性**(只看存在与否、与值无关),
28
+ * 所以 `autoFocus={false}` 必须"移除属性"而不是写 `autofocus="false"`
29
+ * (attribute 通道只能把 false 序列化成字符串,属性照样存在 = 仍然聚焦)。
30
+ *
31
+ * ⚠️ 但 `setProperty` 必须把它映射成 **IDL 名 `autofocus`(全小写)** ——
32
+ * `Reflect.set(el, 'autoFocus', …)` 只会挂一个不反射的 expando,**静默无效**。
33
+ * 映射表见 ops.ts 的 `PROPERTY_IDL_NAMES`。实测(jsdom,与真实 DOM 同语义):
34
+ * IDL `autofocus` → 写 true 得 has=true/idl=true;写 false 得 has=false/idl=false ✓
35
+ * JSX `autoFocus` → 属性与 IDL 都不变(只是 expando) ✗
36
+ */
37
+ 'autoFocus', 'hidden', 'tabIndex', 'colSpan', 'rowSpan', 'open',
38
+ // 只能走 property 的(attribute 路径会静默无效)
39
+ 'innerHTML', 'innerText', 'textContent',
40
+ // 媒体
41
+ 'muted', 'volume', 'currentTime', 'playbackRate'
42
+ ])
43
+
44
+ /** camelCase JSX 属性名 → HTML attribute 名的别名。 */
45
+ const ATTRIBUTE_ALIASES: Readonly<Record<string, string>> = {
46
+ className: 'class',
47
+ htmlFor: 'for',
48
+ autoComplete: 'autocomplete',
49
+ spellCheck: 'spellcheck',
50
+ }
51
+
52
+ /**
53
+ * SVG 里「JSX 写驼峰、真实 DOM 属性是短横线」的那一批。
54
+ *
55
+ * **不能按通用规则把驼峰转短横线** —— SVG 的属性命名是混合的:
56
+ * - 表现属性(就是那些能当 CSS 用的)是短横线:`stroke-width`、`fill-opacity`、`text-anchor`
57
+ * - 结构属性却真是驼峰:`viewBox`、`preserveAspectRatio`、`markerWidth`、`pathLength`
58
+ * 所以只对下面这批做转换;其余同名照旧(`viewBox` 写驼峰本来就是对的)。
59
+ *
60
+ * 写错大小写的后果是完全静默:setAttribute 出一个浏览器不认识的属性,图形照旧没有描边,
61
+ * 没有任何报错。
62
+ */
63
+ const SVG_KEBAB_ATTRIBUTES: ReadonlySet<string> = new Set([
64
+ // stroke
65
+ 'strokeWidth', 'strokeLinecap', 'strokeLinejoin', 'strokeDasharray', 'strokeDashoffset',
66
+ 'strokeMiterlimit', 'strokeOpacity',
67
+ // fill
68
+ 'fillOpacity', 'fillRule',
69
+ // clip(注意 clipPathUnits 是结构属性,不在这里)
70
+ 'clipPath', 'clipRule',
71
+ // 文本对齐
72
+ 'textAnchor', 'dominantBaseline', 'alignmentBaseline', 'baselineShift',
73
+ // 字体
74
+ 'fontFamily', 'fontSize', 'fontSizeAdjust', 'fontStretch', 'fontStyle', 'fontVariant', 'fontWeight',
75
+ 'letterSpacing', 'wordSpacing',
76
+ // marker 的表现属性(markerWidth / markerHeight / markerUnits 是结构属性,不在这里)
77
+ 'markerStart', 'markerMid', 'markerEnd',
78
+ // 颜色
79
+ 'colorInterpolation', 'colorInterpolationFilters', 'colorProfile', 'colorRendering',
80
+ 'floodColor', 'floodOpacity', 'lightingColor', 'stopColor', 'stopOpacity',
81
+ 'glyphOrientationHorizontal', 'glyphOrientationVertical',
82
+ // 渲染与合成
83
+ 'imageRendering', 'paintOrder', 'pointerEvents', 'shapeRendering',
84
+ 'textDecoration', 'textRendering', 'transformOrigin', 'vectorEffect', 'writingMode'
85
+ ])
86
+
87
+ /** 名字是否应走 property 通道(`el[name] = value`)。 */
88
+ export function isPropertyName(name: string): boolean {
89
+ return PROPERTY_NAMES.has(name)
90
+ }
91
+
92
+ /** SVG 驼峰属性名是否应转成短横线形式(`strokeWidth` → `stroke-width`)。 */
93
+ export function isSvgKebabAttribute(name: string): boolean {
94
+ return SVG_KEBAB_ATTRIBUTES.has(name)
95
+ }
96
+
97
+ /**
98
+ * camelCase JSX 属性名 → 真正写进 DOM 的 attribute 名。
99
+ *
100
+ * 注意 property 通道**不**经过这里:`colSpan` / `rowSpan` 的 JS 属性名本就是驼峰。
101
+ */
102
+ export function domAttributeName(name: string): string {
103
+ const alias = ATTRIBUTE_ALIASES[name]
104
+ if (alias !== undefined) return alias
105
+ if (SVG_KEBAB_ATTRIBUTES.has(name)) return name.replace(/[A-Z]/gu, letter => `-${letter.toLowerCase()}`)
106
+ return name
107
+ }