@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,205 @@
1
+ // @vitest-environment jsdom
2
+ /*
3
+ * `Show` + `classList` —— 外部踩坑文档 C 条的对策,从"人肉纪律"变成一行。
4
+ *
5
+ * C 条:条件分支里的输入控件丢焦点 —— `insertDynamic` 按引用比较(`next === current`),
6
+ * 条件表达式一变化就**重建子树**,于是每敲一个字符焦点就丢、滚动位置与内部状态全丢。
7
+ *
8
+ * 判据用**节点身份 + 焦点**(不是"看起来对不对"):
9
+ * - 条件反复变化后,输入元素**还是同一个对象**
10
+ * - `document.activeElement` 始终是它(焦点没丢)
11
+ * - 输入框里的值没被重置
12
+ */
13
+ import { describe, expect, it } from 'vitest'
14
+ import { scheduler, state } from '@vobs/reactivity'
15
+ import { createComponent, createDOMRenderer, createElement, createVobs, setRenderer } from '@vobs/vobs'
16
+ import type { VobsNode } from '@vobs/vobs'
17
+ import { applyClassList, bindAttribute, setStaticProps, Show } from './index'
18
+
19
+ setRenderer(createDOMRenderer())
20
+ const settle = (): void => { scheduler.flush() }
21
+
22
+ describe('Show:保留挂载,条件变化不重建子树', () => {
23
+ function mountShow(when: () => boolean, extra: Record<string, unknown> = {}) {
24
+ const host = document.createElement('main')
25
+ document.body.appendChild(host)
26
+ const app = createVobs({
27
+ render: () => createComponent(Show, {
28
+ get when() { return when() },
29
+ children: () => {
30
+ const input = createElement('input') as HTMLInputElement
31
+ return input
32
+ },
33
+ ...extra
34
+ } as never)
35
+ })
36
+ app.mount(host)
37
+ const input = (): HTMLInputElement | null => host.querySelector('input')
38
+ return { host, app, input, cleanup: () => { app.destroy(); host.remove() } }
39
+ }
40
+
41
+ it('条件切换后输入元素**仍是同一个对象**(没被重建)', () => {
42
+ const open = state(true)
43
+ const view = mountShow(() => open.value)
44
+ const before = view.input()
45
+ expect(before).not.toBeNull()
46
+
47
+ open.value = false
48
+ settle()
49
+ open.value = true
50
+ settle()
51
+ open.value = false
52
+ settle()
53
+ expect(view.input(), 'Show 重建了子树(这正是 C 条那个 bug)').toBe(before)
54
+ view.cleanup()
55
+ })
56
+
57
+ it('条件切换不丢焦点、不重置输入值', () => {
58
+ const open = state(true)
59
+ const view = mountShow(() => open.value)
60
+ const input = view.input()!
61
+ input.focus()
62
+ input.value = '用户输入的内容'
63
+ expect(document.activeElement).toBe(input)
64
+
65
+ open.value = false
66
+ settle()
67
+ open.value = true
68
+ settle()
69
+
70
+ expect(document.activeElement, '条件变化把焦点弄丢了').toBe(input)
71
+ expect(input.value, '条件变化重置了用户输入').toBe('用户输入的内容')
72
+ view.cleanup()
73
+ })
74
+
75
+ it('隐藏时挂 hidden + inert(移出焦点顺序与无障碍树)', () => {
76
+ const open = state(true)
77
+ const view = mountShow(() => open.value)
78
+ const input = view.input()!
79
+
80
+ expect(input.hidden).toBe(false)
81
+ expect(input.hasAttribute('inert')).toBe(false)
82
+
83
+ open.value = false
84
+ settle()
85
+ expect(input.hidden).toBe(true)
86
+ expect(input.hasAttribute('inert')).toBe(true)
87
+
88
+ open.value = true
89
+ settle()
90
+ expect(input.hidden).toBe(false)
91
+ expect(input.hasAttribute('inert'), '重新显示后 inert 没被移除').toBe(false)
92
+ view.cleanup()
93
+ })
94
+
95
+ it('hiddenClass 按需增删,且不影响作者自己的类名', () => {
96
+ const open = state(true)
97
+ const host = document.createElement('main')
98
+ document.body.appendChild(host)
99
+ const app = createVobs({
100
+ render: () => createComponent(Show, {
101
+ get when() { return open.value },
102
+ hiddenClass: 'is-hidden',
103
+ children: () => {
104
+ const el = createElement('div')
105
+ el.setAttribute('class', 'panel custom')
106
+ return el
107
+ }
108
+ } as never)
109
+ })
110
+ app.mount(host)
111
+ const el = host.querySelector('div')!
112
+ expect(el.getAttribute('class')).toBe('panel custom')
113
+
114
+ open.value = false
115
+ settle()
116
+ expect(el.getAttribute('class')).toBe('panel custom is-hidden')
117
+
118
+ open.value = true
119
+ settle()
120
+ expect(el.getAttribute('class'), '作者的类名被抹掉了').toBe('panel custom')
121
+ app.destroy(); host.remove()
122
+ })
123
+
124
+ it('children 不是单个元素 → 抛出并说明该怎么改(不静默退化)', () => {
125
+ const host = document.createElement('main')
126
+ document.body.appendChild(host)
127
+ expect(() => {
128
+ createVobs({
129
+ render: () => createComponent(Show, {
130
+ when: true,
131
+ children: 'plain text' as unknown as VobsNode
132
+ } as never)
133
+ }).mount(host)
134
+ }).toThrowError(/单个元素/u)
135
+ host.remove()
136
+ })
137
+
138
+ it('children 为空 → 抛出', () => {
139
+ const host = document.createElement('main')
140
+ document.body.appendChild(host)
141
+ expect(() => {
142
+ createVobs({ render: () => createComponent(Show, { when: true } as never) }).mount(host)
143
+ }).toThrowError(/不能为空/u)
144
+ host.remove()
145
+ })
146
+ })
147
+
148
+ describe('classList:一行替代手拼类名字符串', () => {
149
+ it('对象形式:真值加类、假值不加', () => {
150
+ const el = createElement('div')
151
+ applyClassList(el, { 'is-open': true, 'is-busy': false, 'is-ready': 1 })
152
+ expect(el.getAttribute('class')).toBe('is-open is-ready')
153
+ })
154
+
155
+ it('数组形式与条件项', () => {
156
+ const el = createElement('div')
157
+ applyClassList(el, ['base', false, null, undefined, 'extra'])
158
+ expect(el.getAttribute('class')).toBe('base extra')
159
+ })
160
+
161
+ it('反复切换**不会越加越多**,也不会抹掉作者的 class', () => {
162
+ const el = createElement('div')
163
+ el.setAttribute('class', 'panel')
164
+ applyClassList(el, { 'is-open': true })
165
+ expect(el.getAttribute('class')).toBe('panel is-open')
166
+ applyClassList(el, { 'is-open': false })
167
+ expect(el.getAttribute('class')).toBe('panel')
168
+ applyClassList(el, { 'is-open': true })
169
+ expect(el.getAttribute('class'), '反复切换后类名叠加了').toBe('panel is-open')
170
+ })
171
+
172
+ it('走真实 props 通道(setStaticProps)也生效 —— 证明接线,不只是算法', () => {
173
+ const el = createElement('div')
174
+ setStaticProps(el, { class: 'panel', classList: { 'is-open': true, 'is-busy': false } })
175
+ expect(el.getAttribute('class')).toBe('panel is-open')
176
+ // 不该写出一个浏览器不认识的 classlist 属性
177
+ expect(el.hasAttribute('classlist')).toBe(false)
178
+ })
179
+
180
+ it('走编译器真实产物那条路(bindAttribute)也是响应式的', () => {
181
+ // 实测编译产物:classList={{...}} → bindAttribute(_el0, "classList", () => ({...}))
182
+ const open = state(true)
183
+ const el = createElement('div')
184
+ el.setAttribute('class', 'panel')
185
+ bindAttribute(el, 'classList', () => ({ 'is-open': open.value, 'is-busy': !open.value }))
186
+ settle()
187
+ expect(el.getAttribute('class')).toBe('panel is-open')
188
+
189
+ open.value = false
190
+ settle()
191
+ expect(el.getAttribute('class')).toBe('panel is-busy')
192
+
193
+ open.value = true
194
+ settle()
195
+ expect(el.getAttribute('class'), '反复切换后类名叠加了').toBe('panel is-open')
196
+ })
197
+
198
+ it('空对象 / 空数组清掉自己贡献的类', () => {
199
+ const el = createElement('div')
200
+ el.setAttribute('class', 'panel')
201
+ applyClassList(el, { 'is-open': true })
202
+ applyClassList(el, {})
203
+ expect(el.getAttribute('class')).toBe('panel')
204
+ })
205
+ })
package/src/show.ts ADDED
@@ -0,0 +1,107 @@
1
+ import { effect } from '@vobs/reactivity'
2
+ import { isVobsFragment, type VobsNode } from './fragment'
3
+ import { removeAttribute, setAttribute, setProperty } from './ops'
4
+
5
+ export interface ShowProps {
6
+ /**
7
+ * 条件。要响应式就传 getter / 信号读取表达式
8
+ * (编译器会把动态 props 自动转成 getter,所以 JSX 里写 `when={open.value}` 即可)。
9
+ */
10
+ readonly when: unknown
11
+ /**
12
+ * 要显示的内容。**必须是单个元素**(`<div>` / `<input>` / 组件返回的元素都行)——
13
+ * 因为"保留挂载 + 切可见性"必须有一个宿主元素来挂 `hidden`/`inert`,
14
+ * 而框架**不会替你插包裹层**(包裹层会改变 flex/grid 布局,是更隐蔽的坑)。
15
+ */
16
+ readonly children?: VobsNode | (() => VobsNode | null | undefined)
17
+ /** 隐藏时额外挂上的类名(可选)。默认只用 `hidden` + `inert`。 */
18
+ readonly hiddenClass?: string
19
+ }
20
+
21
+ /**
22
+ * 条件显示,**保留子树挂载状态**(不重建)。
23
+ *
24
+ * ## 为什么需要它(外部踩坑文档 C 条,★★)
25
+ *
26
+ * `insertDynamic` 按引用比较(`next === current`):条件表达式一变化就**重建子树**。
27
+ * 后果是条件分支里的输入控件**每敲一个字符就丢焦点**、滚动位置与内部状态全丢。
28
+ *
29
+ * 文档给出的对策是「固定渲染 + 响应式 class 显隐」,但那是**人肉纪律** ——
30
+ * 每个页面都要记得这么写。`Show` 把它变成一行:
31
+ *
32
+ * ```tsx
33
+ * <Show when={open.value} hiddenClass="is-hidden">
34
+ * <input /> {/* 条件怎么变,这个 input 都不会被重建,焦点与状态保持 *\/}
35
+ * </Show>
36
+ * ```
37
+ *
38
+ * 与 `{cond ? <A/> : <B/>}` 的分工:
39
+ * - 要**保留状态**(输入框、滚动位置、内部子系统)→ 用 `Show`
40
+ * - 要**真正卸载**(省内存、必须销毁副作用)→ 用 JSX 条件表达式(编译期生成条件工厂)
41
+ *
42
+ * ## 隐藏时同时挂 `inert`
43
+ *
44
+ * 只设 `hidden` 在部分场景下元素仍可被 Tab 到(详见 `@vobs/layout` 的同类处理)。
45
+ * `inert` 把子树移出**焦点顺序与无障碍树**。旧浏览器不支持时 `inert=""` 只是个
46
+ * 无害的未知属性,`hidden` 仍在兜底。
47
+ *
48
+ * ## 边界
49
+ *
50
+ * 子节点不是单个元素(文本 / fragment / 多个节点)时**抛出并说明原因** ——
51
+ * 不静默退化成"卸载"(那会让用户以为状态保住了,实际每变化一次就重建,
52
+ * 正是 C 条那个 bug 的形态)。需要保留状态就请把内容包在一个元素里。
53
+ */
54
+ export function Show(props: ShowProps): VobsNode {
55
+ /*
56
+ * 用 `unknown` 承接再判空:`props.children` 的声明类型不含 `false`,
57
+ * 但运行时 `cond && <X/>` 这类写法确实会给出 `false` ——
58
+ * 直接比较会被 TS 判成"类型不可达"(与 `createComponent` 同一处坑)。
59
+ */
60
+ const resolved: unknown = typeof props.children === 'function' ? props.children() : props.children
61
+ if (resolved === null || resolved === undefined || resolved === false) {
62
+ throw new Error('Vobs Show: children 不能为空 —— 它需要一个宿主元素来挂 hidden/inert。')
63
+ }
64
+ const node = resolved as VobsNode
65
+ if (isVobsFragment(node) || (node as Element).nodeType !== 1) {
66
+ throw new Error(
67
+ 'Vobs Show: children 必须是单个元素。'
68
+ + '「保留挂载 + 切可见性」需要宿主元素,而框架不会替你插包裹层(会改布局)。'
69
+ + '请把内容包进一个元素,例如 <Show when={…}><div>…</div></Show>;'
70
+ + '若确实需要卸载语义,请改用 JSX 条件表达式 {cond ? <A/> : <B/>}。'
71
+ )
72
+ }
73
+ const element = node as Element
74
+
75
+ effect(() => {
76
+ const visible = Boolean(readMaybeGetter(props.when))
77
+ /*
78
+ * 可见性切换只碰这三个东西:`hidden`、`inert`、可选类名。
79
+ * 不读任何信号(`when` 的读取在 effect 内、由调用方提供的 getter 完成),
80
+ * 所以不会自我订阅。
81
+ */
82
+ setProperty(element, 'hidden', !visible)
83
+ if (visible) removeAttribute(element, 'inert')
84
+ else setAttribute(element, 'inert', '')
85
+ if (props.hiddenClass !== undefined) applyExtraClass(element, props.hiddenClass, !visible)
86
+ })
87
+
88
+ return element
89
+ }
90
+
91
+ function readMaybeGetter<T>(value: T | (() => T)): T {
92
+ return typeof value === 'function' ? (value as () => T)() : value
93
+ }
94
+
95
+ /** 按需增删一个类名(不影响作者写在 `class` 里的其它类)。 */
96
+ function applyExtraClass(element: Element, className: string, on: boolean): void {
97
+ const names = element.getAttribute('class')?.split(/\s+/u).filter(Boolean) ?? []
98
+ if (on) {
99
+ if (names.includes(className)) return
100
+ setAttribute(element, 'class', [...names, className].join(' '))
101
+ return
102
+ }
103
+ if (!names.includes(className)) return
104
+ setAttribute(element, 'class', names.filter(name => name !== className).join(' '))
105
+ }
106
+
107
+ export { readMaybeGetter }
@@ -0,0 +1,109 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from 'vitest'
3
+ import { createDOMRenderer, setRenderer, state } from '@vobs/vobs'
4
+ import { bindSpreadProps, createElement } from './index'
5
+
6
+ setRenderer(createDOMRenderer())
7
+
8
+ const flush = async () => { await Promise.resolve(); await Promise.resolve() }
9
+
10
+ /*
11
+ * `bindSpreadProps` 的增量 diff 基准是"上一轮的对象引用"(ops.ts 里 `previous = next`)。
12
+ *
13
+ * 那把 `previous` 存成 `next` 的**同一引用**时,下一轮的
14
+ * `Object.is(previous[key], value)` 读的是同一份值 → **恒等 → 整个 diff 短路**,
15
+ * 移除循环也因 `key in next` 全跳过。
16
+ *
17
+ * 而编译器为 `<div {...props}>` 发射的正是**身份稳定 + getter** 的形状
18
+ * (`bindSpreadProps(_el0, () => props)`,props 的读取经 getter 反应式求值),
19
+ * 所以这不是假想用法:真实产物下改了 props,属性/事件/class **完全不上 DOM**。
20
+ *
21
+ * `spread.test.ts` 的 5 条用例全部走"每轮赋一个新对象"(`props.value = {...}`),
22
+ * 恰好绕开了这个形状 —— 101 条 runtime 用例全绿,却没人问"这条路在真实产物里长什么样"。
23
+ */
24
+ describe('bindSpreadProps 在身份稳定 + getter 的形状下(编译器真实产物形状)', () => {
25
+ it('同一对象引用、getter 值变化时,attribute 必须更新', async () => {
26
+ const title = state('a')
27
+ // 身份稳定:每次都是同一个对象,值经 getter 读取
28
+ const props = { get title() { return title.value } }
29
+ const el = createElement('div')
30
+ bindSpreadProps(el, () => props)
31
+ await flush()
32
+
33
+ // 首次应用
34
+ expect(el.getAttribute('title')).toBe('a')
35
+
36
+ title.value = 'b'
37
+ // effect 是调度执行(不是同步),所以这里要 flush 一轮
38
+ await flush()
39
+ expect(el.getAttribute('title')).toBe('b')
40
+ })
41
+
42
+ it('handler 身份变化时事件必须重绑(且新 handler 生效)', async () => {
43
+ const calls: string[] = []
44
+ const first = () => calls.push('first')
45
+ const second = () => calls.push('second')
46
+ const handler = state(first)
47
+ const props = { get onClick() { return handler.value } }
48
+ const el = createElement('button')
49
+ bindSpreadProps(el, () => props)
50
+ await flush()
51
+
52
+ el.dispatchEvent(new MouseEvent('click', { bubbles: true }))
53
+ expect(calls).toEqual(['first'])
54
+
55
+ handler.value = second
56
+ await flush()
57
+ el.dispatchEvent(new MouseEvent('click', { bubbles: true }))
58
+ // 修复前:diff 恒等短路 → 仍是 first,且 second 从未被挂上
59
+ expect(calls).toEqual(['first', 'second'])
60
+ })
61
+
62
+ it('键从对象里删掉时 attribute 被移除(null 值不移除,那是已钉的契约)', async () => {
63
+ const present = state(true)
64
+ /*
65
+ * 用"键在不在"表达移除,而不是"值变 null":
66
+ * `props.test.ts` 把 null/undefined = "不提供值、保持现状"钉成了契约,
67
+ * 所以这条路径的清除语义是**删键**(下面的移除循环),不是把值设成 null。
68
+ */
69
+ const props = new Proxy({} as Record<string, unknown>, {
70
+ ownKeys: () => (present.value ? ['data-extra'] : []),
71
+ getOwnPropertyDescriptor: () => ({ enumerable: true, configurable: true, value: '1' }),
72
+ get: (_t, key) => (key === 'data-extra' ? '1' : undefined)
73
+ })
74
+ const el = createElement('div')
75
+ bindSpreadProps(el, () => props)
76
+ await flush()
77
+ expect(el.getAttribute('data-extra')).toBe('1')
78
+
79
+ present.value = false
80
+ await flush()
81
+ expect(el.hasAttribute('data-extra')).toBe(false)
82
+ })
83
+
84
+ it('身份稳定时布尔 property 的值变化必须跟进', async () => {
85
+ const flag = state(true)
86
+ const props = { get disabled() { return flag.value } }
87
+ const el = createElement('input') as HTMLInputElement
88
+ bindSpreadProps(el as unknown as Element, () => props)
89
+ await flush()
90
+ expect(el.disabled).toBe(true)
91
+
92
+ flag.value = false
93
+ await flush()
94
+ expect(el.disabled).toBe(false)
95
+ })
96
+
97
+ it('身份稳定时 value property 也必须跟进(受控输入)', async () => {
98
+ const text = state('v1')
99
+ const props = { get value() { return text.value } }
100
+ const el = createElement('input') as HTMLInputElement
101
+ bindSpreadProps(el as unknown as Element, () => props)
102
+ await flush()
103
+ expect(el.value).toBe('v1')
104
+
105
+ text.value = 'v2'
106
+ await flush()
107
+ expect(el.value).toBe('v2')
108
+ })
109
+ })
@@ -0,0 +1,86 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from 'vitest'
3
+ import { createDOMRenderer, setRenderer, state } from '@vobs/vobs'
4
+ import { bindSpreadProps, createElement } from './index'
5
+
6
+ setRenderer(createDOMRenderer())
7
+
8
+ const flush = async () => { await Promise.resolve(); await Promise.resolve() }
9
+
10
+ /*
11
+ * `{...props}` 展开。
12
+ *
13
+ * 编译器原来只发射 `spreadProps` —— 创建时应用一次就完了,于是
14
+ * 「改了 props 不生效」和「对象里删掉的键永远留在 DOM 上」两个问题都不报错。
15
+ * 现在发射 bindSpreadProps,每轮增量应用。
16
+ */
17
+ describe('bindSpreadProps', () => {
18
+ it('对象变了会重新应用(原来只在创建时应用一次)', async () => {
19
+ const props = state<Record<string, unknown>>({ title: 'a', id: 'x' })
20
+ const el = createElement('div')
21
+ bindSpreadProps(el, () => props.value)
22
+ await flush()
23
+ expect(el.getAttribute('title')).toBe('a')
24
+ expect(el.getAttribute('id')).toBe('x')
25
+
26
+ props.value = { title: 'b', id: 'x' }
27
+ await flush()
28
+ expect(el.getAttribute('title')).toBe('b')
29
+ })
30
+
31
+ it('消失的键会被移除(原来会永远留在 DOM 上)', async () => {
32
+ const props = state<Record<string, unknown>>({ title: 'a', 'data-extra': '1' })
33
+ const el = createElement('div')
34
+ bindSpreadProps(el, () => props.value)
35
+ await flush()
36
+ expect(el.hasAttribute('data-extra')).toBe(true)
37
+
38
+ props.value = { title: 'a' }
39
+ await flush()
40
+ expect(el.hasAttribute('data-extra')).toBe(false)
41
+ expect(el.getAttribute('title')).toBe('a')
42
+ })
43
+
44
+ it('property 键消失时复位(布尔回 false,其余回空串)', async () => {
45
+ const props = state<Record<string, unknown>>({ disabled: true, value: 'v' })
46
+ const el = createElement('input') as HTMLInputElement
47
+ bindSpreadProps(el as unknown as Element, () => props.value)
48
+ await flush()
49
+ expect(el.disabled).toBe(true)
50
+ expect(el.value).toBe('v')
51
+
52
+ props.value = {}
53
+ await flush()
54
+ expect(el.disabled).toBe(false)
55
+ expect(el.value).toBe('')
56
+ })
57
+
58
+ it('事件处理器被替换后旧的不再生效', async () => {
59
+ const calls: string[] = []
60
+ const props = state<Record<string, unknown>>({ onClick: () => calls.push('first') })
61
+ const el = createElement('button')
62
+ bindSpreadProps(el, () => props.value)
63
+ await flush()
64
+
65
+ el.dispatchEvent(new MouseEvent('click', { bubbles: true }))
66
+ expect(calls).toEqual(['first'])
67
+
68
+ props.value = { onClick: () => calls.push('second') }
69
+ await flush()
70
+ el.dispatchEvent(new MouseEvent('click', { bubbles: true }))
71
+ expect(calls).toEqual(['first', 'second'])
72
+ })
73
+
74
+ it('事件处理器被移除后不再触发', async () => {
75
+ const calls: string[] = []
76
+ const props = state<Record<string, unknown>>({ onClick: () => calls.push('hit') })
77
+ const el = createElement('button')
78
+ bindSpreadProps(el, () => props.value)
79
+ await flush()
80
+
81
+ props.value = {}
82
+ await flush()
83
+ el.dispatchEvent(new MouseEvent('click', { bubbles: true }))
84
+ expect(calls).toEqual([])
85
+ })
86
+ })
package/src/svg.test.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
  import { describe, expect, it } from 'vitest'
3
- import { createDOMRenderer, setRenderer, createElement, insertBefore } from '@vobs/vobs'
3
+ import { createDOMRenderer, setRenderer, getRenderer, createElement, createSvgElement, insertBefore } from '@vobs/vobs'
4
+ import { SVG_NAMESPACE } from './svg'
4
5
 
5
6
  // 回归(Labelune 踩坑备忘):createElement('svg'/'rect') 此前走 HTML namespace,
6
7
  // 产物是 HTMLUnknownElement——整棵 SVG 子树静默不渲染且无报错。
@@ -55,5 +56,31 @@ describe('svg namespace element creation', () => {
55
56
  const rect = createElement('rect')
56
57
  // 旧自定义渲染器兜底:按 HTML 创建(保持原行为,不抛错)
57
58
  expect(rect.namespaceURI).not.toBe('http://www.w3.org/2000/svg')
59
+
60
+ // 还原:全局渲染器是共享状态,换了不还原会让后面的用例在 stub 上跑(曾经真的踩到)
61
+ setRenderer(createDOMRenderer())
58
62
  })
59
63
  })
64
+
65
+ /*
66
+ * SVG 命名空间继承:a / title / style / script 与 HTML 同名,光看名字会建出 HTML 元素。
67
+ * 编译器在 <svg> 子树里显式改走 createSvgElement(见 compiler 的同名测试)。
68
+ */
69
+ describe('createSvgElement 强制 SVG 命名空间', () => {
70
+ it('与 HTML 同名的标签也建在 SVG 命名空间里', () => {
71
+ const r = getRenderer()
72
+ console.log('DEBUG op =', createSvgElement('a').namespaceURI)
73
+ console.log('DEBUG renderer.createSvgElement =', typeof r.createSvgElement, '→', r.createSvgElement?.('a').namespaceURI)
74
+ console.log('DEBUG createElement(svg) =', createElement('svg').namespaceURI)
75
+ const anchor = createSvgElement('a')
76
+ expect(anchor.namespaceURI).toBe(SVG_NAMESPACE)
77
+ expect(anchor.constructor.name).not.toBe('HTMLAnchorElement')
78
+
79
+ // 对照:同样名字走 createElement 就是 HTML 元素
80
+ expect(createElement('a').namespaceURI).not.toBe(SVG_NAMESPACE)
81
+ })
82
+
83
+ it('title 同样', () => {
84
+ expect(createSvgElement('title').namespaceURI).toBe(SVG_NAMESPACE)
85
+ })
86
+ })
package/src/svg.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * SVG 标签与命名空间 —— 编译期判定提升、运行期决定怎么建节点的**同一份依据**。
3
+ *
4
+ * 两处都需要知道「这个名字是不是 SVG 标签」:
5
+ * - 运行期:`document.createElement('rect')` 得到的是 HTMLUnknownElement,
6
+ * 整棵 SVG 子树都不渲染(且没有报错),所以必须走 createElementNS
7
+ * - 编译期:静态子树被提升成 HTML 模板串再 cloneNode,而模板串是**用 HTML 解析器**
8
+ * 解析的 —— `<g>`、`<rect>` 同样变成 HTMLUnknownElement。这条路径漏了整整一个大版本
9
+ * (1.7.4 只修了 createElement 那条入口,svg.test.ts 也只测那条)。
10
+ */
11
+ export const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
12
+
13
+ /**
14
+ * SVG 标签全集(= TypeScript SVGElementTagNameMap 的键,运行时分发依据)。
15
+ * 与 React 的运行时清单同思路:只含纯 SVG 标签,刻意排除与 HTML 同名的
16
+ * a/script/style/title(它们在 JSX 类型里归 HTMLElement,按 HTML 创建)。
17
+ */
18
+ export const SVG_TAGS: ReadonlySet<string> = new Set([
19
+ 'animate', 'animateMotion', 'animateTransform', 'circle', 'clipPath', 'defs', 'desc',
20
+ 'ellipse', 'feBlend', 'feColorMatrix', 'feComponentTransfer', 'feComposite',
21
+ 'feConvolveMatrix', 'feDiffuseLighting', 'feDisplacementMap', 'feDistantLight',
22
+ 'feDropShadow', 'feFlood', 'feFuncA', 'feFuncB', 'feFuncG', 'feFuncR',
23
+ 'feGaussianBlur', 'feImage', 'feMerge', 'feMergeNode', 'feMorphology', 'feOffset',
24
+ 'fePointLight', 'feSpecularLighting', 'feSpotLight', 'feTile', 'feTurbulence',
25
+ 'filter', 'foreignObject', 'g', 'image', 'line', 'linearGradient', 'marker', 'mask',
26
+ 'metadata', 'mpath', 'path', 'pattern', 'polygon', 'polyline', 'radialGradient',
27
+ 'rect', 'set', 'stop', 'svg', 'switch', 'symbol', 'text', 'textPath', 'tspan',
28
+ 'use', 'view'
29
+ ])
30
+
31
+ /** 是否是 SVG 专属标签(按名字判定,不看父级)。 */
32
+ export function isSvgTag(tag: string): boolean {
33
+ return SVG_TAGS.has(tag)
34
+ }