@vobs/runtime 1.7.8 → 1.8.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.
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
package/src/props.test.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  // @vitest-environment jsdom
2
2
  import { describe, expect, it } from 'vitest'
3
3
  import { createDOMRenderer, setRenderer } from '@vobs/vobs'
4
- import { setProperty, setStaticProps, spreadProps } from './index'
4
+ import { state } from '@vobs/reactivity'
5
+ import { bindAttribute, bindProperty, setProperty, setStaticProps, spreadProps } from './index'
5
6
 
6
7
  setRenderer(createDOMRenderer())
7
8
 
@@ -83,3 +84,62 @@ describe('static and spread prop application', () => {
83
84
  expect(node.hasAttribute('title')).toBe(false)
84
85
  })
85
86
  })
87
+
88
+ /*
89
+ * null / undefined 不该被 String() 成字面量写进 DOM。
90
+ *
91
+ * bindText 早就把 null/undefined 当 '',静态路径 setStaticProps 也跳过它们,
92
+ * 只有响应式的 bindAttribute / bindProperty 漏了 —— 于是 placeholder={undefined}
93
+ * 会写进 "undefined",input.value = undefined 也会被 DOM 强转成字符串。
94
+ */
95
+ describe('绑定 null / undefined', () => {
96
+ // vobs 的更新是微任务批处理:写完要让它跑一轮,断言才成立
97
+ const flush = async () => { await Promise.resolve(); await Promise.resolve() }
98
+
99
+ it('bindAttribute 不把 undefined 写成字面量', async () => {
100
+ const input = document.createElement('input')
101
+ const source = state<unknown>('提示')
102
+ bindAttribute(input, 'placeholder', source)
103
+ await flush()
104
+ expect(input.getAttribute('placeholder')).toBe('提示')
105
+
106
+ source.value = undefined
107
+ await flush()
108
+ expect(input.getAttribute('placeholder')).not.toBe('undefined')
109
+
110
+ source.value = 'ok'
111
+ await flush()
112
+ expect(input.getAttribute('placeholder')).toBe('ok')
113
+ })
114
+
115
+ it('bindProperty 不把 undefined 写成字面量,但 false 必须透传', async () => {
116
+ const input = document.createElement('input')
117
+ const source = state<unknown>('a')
118
+ bindProperty(input, 'value', source)
119
+ await flush()
120
+ expect(input.value).toBe('a')
121
+
122
+ source.value = undefined
123
+ await flush()
124
+ expect(input.value).not.toBe('undefined')
125
+
126
+ const disabled = state<unknown>(true)
127
+ bindProperty(input, 'disabled', disabled)
128
+ await flush()
129
+ expect(input.disabled).toBe(true)
130
+ disabled.value = false
131
+ await flush()
132
+ expect(input.disabled).toBe(false)
133
+ })
134
+
135
+ it('null 同样被挡住', async () => {
136
+ const div = document.createElement('div')
137
+ const source = state<unknown>('x')
138
+ bindAttribute(div, 'title', source)
139
+ await flush()
140
+ expect(div.getAttribute('title')).toBe('x')
141
+ source.value = null
142
+ await flush()
143
+ expect(div.getAttribute('title')).not.toBe('null')
144
+ })
145
+ })
@@ -0,0 +1,144 @@
1
+ // @vitest-environment jsdom
2
+ /*
3
+ * 回归:**坏的回调 ref 不得打断 Owner 的 dispose 级联**。
4
+ *
5
+ * 实测来源(端到端交互冒烟):playground 的 `<ul ref={attachList}>` 里
6
+ * `attachList(node)` 直接 `insertList(node, …)` 没判空。回调 ref 会在**卸载时以 null 调用**
7
+ * (React 语义),于是收到 null 时抛
8
+ * TypeError: Cannot read properties of null (reading 'insertBefore')
9
+ * 而该异常从 `onDispose` 里**冒了出去** —— 导致同一 Owner 后续的 cleanup
10
+ * (effect 解绑、监听移除、其它 ref 清理)**全部不执行**。
11
+ *
12
+ * 这与 `owner.ts:114-128` 已经修过的"子 Owner 抛错不能中断级联"是同一类缺陷:
13
+ * 清理路径必须逐个隔离,不能因为一个 cleanup 失败就丢下其余的。
14
+ */
15
+ import { describe, expect, it, vi } from 'vitest'
16
+ import { createOwner, effect, state } from '@vobs/reactivity'
17
+ import { createDOMRenderer } from '@vobs/dom'
18
+ import { setRenderer } from '@vobs/vobs'
19
+ import { createElement, insertBefore, setRef } from './index'
20
+
21
+ setRenderer(createDOMRenderer())
22
+
23
+ const flush = async (): Promise<void> => {
24
+ for (let i = 0; i < 4; i++) await Promise.resolve()
25
+ }
26
+
27
+ describe('回调 ref 在卸载时收到 null 的行为', () => {
28
+ it('回调 ref 在挂载时收到节点、在 Owner 销毁时收到 null', () => {
29
+ const owner = createOwner()
30
+ const el = createElement('div')
31
+ const seen: Array<unknown> = []
32
+ owner.run(() => { setRef(el, (value: unknown) => { seen.push(value) }) })
33
+ expect(seen).toEqual([el])
34
+
35
+ owner.dispose()
36
+ // React 语义:卸载时以 null 调用
37
+ expect(seen).toEqual([el, null])
38
+ })
39
+
40
+ it('坏的回调 ref(不判空、收到 null 就抛)不得打断 dispose 级联', async () => {
41
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
42
+ const owner = createOwner()
43
+ const el = createElement('div')
44
+ const value = state(0)
45
+ let effectRuns = 0
46
+ let laterCleanupRan = false
47
+
48
+ owner.run(() => {
49
+ // 一个会在收到 null 时抛错的回调 ref(模拟未判空的 attachList)
50
+ setRef(el, (node: unknown) => {
51
+ if (node === null) throw new TypeError("Cannot read properties of null (reading 'insertBefore')")
52
+ })
53
+ // 这个 effect 代表"同一 Owner 里后续的 cleanup"
54
+ effect(() => { value.value; effectRuns += 1 })
55
+ // 再挂一个一定该执行的清理
56
+ owner.onDispose(() => { laterCleanupRan = true })
57
+ })
58
+
59
+ expect(effectRuns).toBe(1)
60
+
61
+ // 关键:dispose 不该因为那个坏 ref 回调而抛出去
62
+ expect(() => owner.dispose()).not.toThrow()
63
+
64
+ // 关键:后续清理必须都跑过(这正是修复前被丢掉的)
65
+ expect(laterCleanupRan).toBe(true)
66
+ value.value = 1
67
+ await flush()
68
+ expect(effectRuns).toBe(1) // effect 已被解绑
69
+
70
+ // 异常不该无声:要么记录,要么冒泡 —— 这里选择记录
71
+ expect(spy).toHaveBeenCalled()
72
+ spy.mockRestore()
73
+ })
74
+
75
+ it('同 owner 内 ref 改指只留一条清理注册(不留陈旧项)', () => {
76
+ const owner = createOwner()
77
+ const a = createElement('div')
78
+ const b = createElement('div')
79
+ const target = { current: null as unknown }
80
+
81
+ const before = owner.mark().cleanups
82
+ owner.run(() => { setRef(a, target) })
83
+ expect(owner.mark().cleanups - before).toBe(1)
84
+ expect(target.current).toBe(a)
85
+
86
+ owner.run(() => { setRef(b, target) })
87
+ /*
88
+ * 关键:第二次绑定**替换**掉第一条注册,而不是追加。
89
+ * 修复前这里会变成 2 条 —— 销毁时逆序跑两条清理,其中一条的守卫
90
+ * (`target.current !== node`)会因为另一条已经把值清成 null 而"恰好通过",
91
+ * 于是同一个 ref 被清两次。现在只留最新那条。
92
+ */
93
+ expect(owner.mark().cleanups - before).toBe(1)
94
+ expect(target.current).toBe(b)
95
+
96
+ // 销毁时把 ref 清成 null 是**既定语义**(React 的 unmount ref 回调同样收到 null)
97
+ owner.dispose()
98
+ expect(target.current).toBeNull()
99
+ })
100
+
101
+ it('跨 owner 改指时,先销毁的那个不覆盖后绑定的值', () => {
102
+ const first = createOwner()
103
+ const second = createOwner()
104
+ const b = createElement('div')
105
+ const c = createElement('div')
106
+ const shared = { current: null as unknown }
107
+ first.run(() => { setRef(b, shared) })
108
+ second.run(() => { setRef(c, shared) })
109
+ first.dispose()
110
+ expect(shared.current).toBe(c) // 已被 second 改指,first 的清理不该动它
111
+ second.dispose()
112
+ expect(shared.current).toBeNull()
113
+ })
114
+
115
+ it('同一个 ref 绑到不同 owner 时各自独立清理(不互相摘除)', () => {
116
+ const first = createOwner()
117
+ const second = createOwner()
118
+ const target = { current: null as unknown }
119
+ const a = createElement('div')
120
+ const b = createElement('div')
121
+ first.run(() => { setRef(a, target) })
122
+ second.run(() => { setRef(b, target) })
123
+ second.dispose()
124
+ expect(target.current).toBeNull()
125
+ // first 的注册必须还在(属于另一个 owner),销毁时照常清理
126
+ first.dispose()
127
+ expect(target.current).toBeNull()
128
+ })
129
+
130
+ it('ref 指向的节点被移除后,后续插入不该拿到 null 父节点', () => {
131
+ const host = createElement('div')
132
+ const list = createElement('ul')
133
+ insertBefore(host, list, null)
134
+ // 判空守卫的形态(修复后 playground 的写法)
135
+ const attach = (node: Element | null): void => {
136
+ if (!node) return
137
+ const li = createElement('li')
138
+ insertBefore(node, li, null)
139
+ }
140
+ attach(list)
141
+ expect(list.children.length).toBe(1)
142
+ expect(() => attach(null)).not.toThrow()
143
+ })
144
+ })
package/src/ref.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { getCurrentOwner } from '@vobs/reactivity'
1
+ import { getCurrentOwner, type Owner } from '@vobs/reactivity'
2
+ import { formatVobsError } from './error'
2
3
 
3
4
  /** A mutable reference populated when a host node is mounted. */
4
5
  export interface Ref<T extends object = Node> {
@@ -11,6 +12,32 @@ export function ref<T extends object = Node>(initialValue: T | null = null): Ref
11
12
  return { current: initialValue }
12
13
  }
13
14
 
15
+ /*
16
+ * 每个 (Owner, ref 目标) 只保留**一条**清理注册。
17
+ *
18
+ * 为什么需要:`ref` 会被先后绑到不同节点(同一个 ref 对象、节点被替换;
19
+ * 或条件分支里换了宿主元素)。若每次都 `onDispose` 追加一条,销毁时按注册**逆序**执行,
20
+ * 后注册的那条先把 `target.current` 清成 `null`,于是先注册那条的守卫
21
+ * (`isObjectRef(target) && target.current !== node`)**恰好通过**,再清一次 ——
22
+ * 最终读到 `null` 而不是当前节点。
23
+ *
24
+ * 用 WeakMap<Owner, WeakMap<target, cleanup>> 而不是往 Owner 上挂属性:
25
+ * Owner 是 class 实例,挂新属性会把它推进字典模式(reactivity/src/owner.ts 记过这笔代价)。
26
+ * target 可弱引用(对象),函数型 ref 也可弱引用,所以不阻止回收。
27
+ */
28
+ const refCleanups = new WeakMap<Owner, WeakMap<object, () => void>>()
29
+
30
+ function rememberRefCleanup(owner: Owner, target: object, cleanup: () => void): void {
31
+ let byTarget = refCleanups.get(owner)
32
+ if (!byTarget) {
33
+ byTarget = new WeakMap()
34
+ refCleanups.set(owner, byTarget)
35
+ }
36
+ const previous = byTarget.get(target)
37
+ if (previous) owner.removeCleanup(previous)
38
+ byTarget.set(target, cleanup)
39
+ }
40
+
14
41
  /** Bind a host node to an object or callback ref and clear it with its Owner. */
15
42
  export function setRef<T extends object>(node: T, target: unknown): void {
16
43
  if (!isRefTarget<T>(target)) return
@@ -19,11 +46,28 @@ export function setRef<T extends object>(node: T, target: unknown): void {
19
46
  if (isSSRNode(node)) return
20
47
  const owner = getCurrentOwner()
21
48
  assignRef(target, node)
22
- owner?.onDispose(() => {
23
- // Do not clear a ref that has since been reassigned to another node.
24
- if (isObjectRef(target) && target.current !== node) return
25
- assignRef(target, null)
26
- })
49
+ if (!owner) return
50
+ const cleanup = (): void => {
51
+ /*
52
+ * 清除 ref 时**必须自己吞掉异常**,不能让 user 的 ref 回调把 dispose 级联打断。
53
+ *
54
+ * `assignRef` 内部已经 try/catch + `console.error`(回调是用户代码,不该让挂载失败),
55
+ * 但那条保护只覆盖"赋值"这一步 —— 而这里还有一个**可能抛错的前置判断**。
56
+ * 按 React 语义,函数型 ref 在卸载时**本来就该收到 `null`**;若回调没判空
57
+ * (如 `node => insertList(node, …)`),它会抛,而异常从 onDispose 冒出去会
58
+ * **中断 Owner 的整个清理循环**(实测:/runtime 页面的 `<ul ref={attachList}>`)。
59
+ * 这与 owner.ts 已修的"子 Owner 抛错不能中断级联"是同一类缺陷。
60
+ */
61
+ try {
62
+ // Do not clear a ref that has since been reassigned to another node.
63
+ if (isObjectRef(target) && target.current !== node) return
64
+ assignRef(target, null)
65
+ } catch (error) {
66
+ console.error(formatVobsError(error, { includeStack: true }))
67
+ }
68
+ }
69
+ rememberRefCleanup(owner, target as object, cleanup)
70
+ owner.onDispose(cleanup)
27
71
  }
28
72
 
29
73
  function isSSRNode(value: object): boolean {
@@ -44,7 +88,12 @@ function assignRef<T extends object>(target: RefTarget<T>, value: T | null): voi
44
88
  try {
45
89
  if (typeof target === 'function') target(value)
46
90
  else target.current = value
47
- } catch {
48
- // Ref callbacks are user code; never make mounting fail because of them.
91
+ } catch (error) {
92
+ /*
93
+ * ref 回调是用户代码,挂载不该因为它失败 —— 但也不能**完全无声**:
94
+ * 回调抛错时 ref 拿不到节点,界面看起来正常,用户完全不知道为什么。
95
+ * 保持吞掉(不冒泡),但打一条控制台记录。
96
+ */
97
+ console.error(formatVobsError(error, { includeStack: true }))
49
98
  }
50
99
  }
@@ -0,0 +1,97 @@
1
+ // @vitest-environment jsdom
2
+ /*
3
+ * `removeEventListener(node, event, handler)` **必须比 handler 身份**。
4
+ *
5
+ * 这是 DOM `removeEventListener` 的既定语义:只有 `(type, listener, capture)` 三者都一致
6
+ * 才摘除;用另一个 listener 调用是 **no-op**。
7
+ *
8
+ * 修复前:实现忽略传入的 `handler` —— 只要 `(node,event)` 上有绑定就一律摘掉并删除绑定记录。
9
+ * 于是"取消自己那次"的调用把**当前生效的别人的**监听弄没了。
10
+ * 实测形态:`addEventListener(el,'click',A)` 之后 `removeEventListener(el,'click',B)`
11
+ * 会把 A 摘掉,A 从此再也收不到事件。
12
+ */
13
+ import { describe, expect, it, vi } from 'vitest'
14
+ import { createDOMRenderer } from '@vobs/dom'
15
+ import { setRenderer } from '@vobs/vobs'
16
+ import { addEventListener, removeEventListener } from './ops'
17
+
18
+ setRenderer(createDOMRenderer())
19
+
20
+ describe('removeEventListener 按 handler 身份摘除', () => {
21
+ it('传另一个 handler 是 no-op,当前绑定仍然生效', () => {
22
+ const el = document.createElement('button')
23
+ const a = vi.fn()
24
+ const b = vi.fn()
25
+ addEventListener(el, 'click', a)
26
+
27
+ removeEventListener(el, 'click', b) // 修复前:把 a 摘掉了
28
+
29
+ el.dispatchEvent(new MouseEvent('click', { bubbles: true }))
30
+ expect(a, '用别的 handler 调 remove 把当前绑定误摘了').toHaveBeenCalledTimes(1)
31
+ expect(b).not.toHaveBeenCalled()
32
+ })
33
+
34
+ it('传同一个 handler 才真的摘除', () => {
35
+ const el = document.createElement('button')
36
+ const a = vi.fn()
37
+ addEventListener(el, 'click', a)
38
+
39
+ removeEventListener(el, 'click', a)
40
+ el.dispatchEvent(new MouseEvent('click', { bubbles: true }))
41
+ expect(a).not.toHaveBeenCalled()
42
+ })
43
+
44
+ it('误摘失败之后,正确的摘除仍然有效(绑定记录没被破坏)', () => {
45
+ const el = document.createElement('button')
46
+ const a = vi.fn()
47
+ const b = vi.fn()
48
+ addEventListener(el, 'click', a)
49
+
50
+ removeEventListener(el, 'click', b) // no-op
51
+ removeEventListener(el, 'click', a) // 真正摘掉
52
+ el.dispatchEvent(new MouseEvent('click', { bubbles: true }))
53
+ expect(a).not.toHaveBeenCalled()
54
+ })
55
+
56
+ it('重绑之后按新 handler 摘除(绑定记录跟着走)', () => {
57
+ const el = document.createElement('button')
58
+ const a = vi.fn()
59
+ const b = vi.fn()
60
+ addEventListener(el, 'click', a)
61
+ addEventListener(el, 'click', b) // 重绑替换
62
+
63
+ removeEventListener(el, 'click', a) // 旧 handler:已不是当前绑定 → no-op
64
+ el.dispatchEvent(new MouseEvent('click', { bubbles: true }))
65
+ expect(b).toHaveBeenCalledTimes(1)
66
+ expect(a).not.toHaveBeenCalled()
67
+
68
+ removeEventListener(el, 'click', b) // 当前绑定 → 摘掉
69
+ el.dispatchEvent(new MouseEvent('click', { bubbles: true }))
70
+ expect(b).toHaveBeenCalledTimes(1)
71
+ })
72
+
73
+ it('没有绑定记录时,按传入的 handler 兜底摘一次(渲染器直连仍可用)', () => {
74
+ const el = document.createElement('button')
75
+ const direct = vi.fn()
76
+ const renderer = createDOMRenderer()
77
+ renderer.addEventListener(el, 'click', direct) // 绕过 bindEvent 直接绑
78
+
79
+ removeEventListener(el, 'click', direct)
80
+ el.dispatchEvent(new MouseEvent('click', { bubbles: true }))
81
+ expect(direct).not.toHaveBeenCalled()
82
+ })
83
+
84
+ it('多个事件类型互不干扰', () => {
85
+ const el = document.createElement('button')
86
+ const onClick = vi.fn()
87
+ const onFocus = vi.fn()
88
+ addEventListener(el, 'click', onClick)
89
+ addEventListener(el, 'focus', onFocus)
90
+
91
+ removeEventListener(el, 'click', onClick)
92
+ el.dispatchEvent(new MouseEvent('click', { bubbles: true }))
93
+ el.dispatchEvent(new FocusEvent('focus'))
94
+ expect(onClick).not.toHaveBeenCalled()
95
+ expect(onFocus).toHaveBeenCalledTimes(1)
96
+ })
97
+ })
package/src/renderer.ts CHANGED
@@ -16,7 +16,9 @@ export interface VobsRenderer<
16
16
  removeChild(parent: NodeType, child: NodeType): void
17
17
  setTextContent(node: TextNode, content: string): void
18
18
  setProperty(node: ElementNode, key: string, value: unknown): void
19
- setAttribute(node: ElementNode, key: string, value: string): void
19
+ setAttribute(node: ElementNode, key: string, value: string): void
20
+ /** 删除 attribute。可选:老自定义渲染器没实现时运行时退回 setAttribute(key, '')。 */
21
+ removeAttribute?(node: ElementNode, key: string): void
20
22
  addEventListener(node: ElementNode, event: string, handler: EventListener): void
21
23
  removeEventListener(node: ElementNode, event: string, handler: EventListener): void
22
24
  nextSibling(node: NodeType): NodeType | null
@@ -0,0 +1,104 @@
1
+ // @vitest-environment jsdom
2
+ /*
3
+ * `<select value={…}>` 插入 option 时的值重放,**不得建立隐藏订阅**。
4
+ *
5
+ * 机制:`bindProperty(select,'value',…)` 会注册一个 reader(`ops.ts` 的
6
+ * `selectValueReaders`),插入 `<option>` 时同步重放当前值。
7
+ * 而插入往往发生在某个 effect 运行期间(`insertList`/`insertDynamic` 都由 effect 驱动)——
8
+ * 若重放里的读取被算进**外层 effect 的依赖**,该 effect 以后会被那个信号唤醒:
9
+ * 依赖图上多一条没人打算建立的边("隐藏订阅"),表现为"插选项时某个不相关的 effect 重跑了"。
10
+ *
11
+ * 判据用**行为**:外层 effect 的重跑次数。
12
+ * 修复前:插入 option 时外层 effect 会额外重跑(因为它意外订阅了值信号)。
13
+ * 修复后:外层 effect 只被它自己读的信号驱动。
14
+ */
15
+ import { describe, expect, it } from 'vitest'
16
+ import { createDOMRenderer, setRenderer, state } from '@vobs/vobs'
17
+ import { effect, scheduler } from '@vobs/reactivity'
18
+ import { bindProperty, createElement, insertBefore, setAttribute } from './index'
19
+
20
+ setRenderer(createDOMRenderer())
21
+ const settle = (): void => { scheduler.flush() }
22
+
23
+ function optionFor(value: string): HTMLOptionElement {
24
+ const option = createElement('option') as HTMLOptionElement
25
+ setAttribute(option, 'value', value)
26
+ return option
27
+ }
28
+
29
+ describe('<select> value 重放不得建立隐藏订阅', () => {
30
+ it('插入 option 不会让"值信号"之外的 effect 被唤醒', () => {
31
+ const select = createElement('select') as HTMLSelectElement
32
+ const selectedValue = state('b')
33
+ const unrelated = state(0)
34
+
35
+ bindProperty(select, 'value', () => selectedValue.value)
36
+
37
+ // 外层 effect:只读 `unrelated`,与 select 的值无关
38
+ let runs = 0
39
+ const stop = effect(() => {
40
+ runs += 1
41
+ void unrelated.value
42
+ // 在 effect 内插入选项 —— 模拟列表驱动的动态 option
43
+ const option = optionFor('a')
44
+ insertBefore(select, option, null)
45
+ })
46
+ settle()
47
+ const afterFirst = runs
48
+ expect(afterFirst).toBeGreaterThanOrEqual(1)
49
+
50
+ // 改**值信号**:外层 effect 没读过它,不该被唤醒
51
+ selectedValue.value = 'a'
52
+ settle()
53
+ expect(runs, '插入 option 的路径让外层 effect 意外订阅了 select 的值信号').toBe(afterFirst)
54
+
55
+ stop.dispose()
56
+ })
57
+
58
+ it('重放仍然生效:动态插入的 option 能命中已绑定的值', () => {
59
+ const select = createElement('select') as HTMLSelectElement
60
+ const selectedValue = state('b')
61
+ bindProperty(select, 'value', () => selectedValue.value)
62
+ settle()
63
+
64
+ insertBefore(select, optionFor('a'), null)
65
+ insertBefore(select, optionFor('b'), null)
66
+ settle()
67
+
68
+ // 值 'b' 对应的 option 现在存在了 → select.value 应当命中
69
+ expect(select.value).toBe('b')
70
+ })
71
+
72
+ it('值信号变化本身仍然驱动 select.value(绑定没被 untrack 弄坏)', () => {
73
+ const select = createElement('select') as HTMLSelectElement
74
+ const selectedValue = state('a')
75
+ bindProperty(select, 'value', () => selectedValue.value)
76
+ insertBefore(select, optionFor('a'), null)
77
+ insertBefore(select, optionFor('b'), null)
78
+ settle()
79
+ expect(select.value).toBe('a')
80
+
81
+ selectedValue.value = 'b'
82
+ settle()
83
+ expect(select.value, '绑定的 effect 没跟上值信号').toBe('b')
84
+ })
85
+
86
+ it('optgroup 内的 option 也会触发重放(既有语义不变)', () => {
87
+ const select = createElement('select') as HTMLSelectElement
88
+ const selectedValue = state('x')
89
+ bindProperty(select, 'value', () => selectedValue.value)
90
+ const group = createElement('optgroup')
91
+ insertBefore(select, group, null)
92
+ insertBefore(group, optionFor('x'), null)
93
+ settle()
94
+ expect(select.value).toBe('x')
95
+ })
96
+
97
+ it('没有绑定值的 select 插入 option 是安全的(reader 不存在)', () => {
98
+ const select = createElement('select') as HTMLSelectElement
99
+ expect(() => {
100
+ insertBefore(select, optionFor('a'), null)
101
+ }).not.toThrow()
102
+ expect(select.value).toBe('a')
103
+ })
104
+ })