@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
package/src/dynamic.ts CHANGED
@@ -19,8 +19,9 @@ interface ListEntry<T> {
19
19
  owner: Owner
20
20
  viewOwner: Owner
21
21
  item: Signal<T>
22
- /** 最近一次赋值的原始 item:供比较与刷新使用,避免在列表 effect 内读取 item 信号造成自依赖。 */
23
- value: T
22
+ /** 最近一次赋值的原始 item:供比较与刷新使用,避免在列表 effect 内读取 item 信号造成自依赖。
23
+ * (刻意不叫 value —— `vobs check` 的 VOBS_C210 会把 `X.value` 的读写当成信号自订阅)。 */
24
+ current: T
24
25
  index: number
25
26
  }
26
27
 
@@ -161,6 +162,45 @@ export function insertList<T>(
161
162
  if (!allKeyed) keys = null
162
163
  }
163
164
 
165
+ /*
166
+ * 快路径:key 序列与上一轮**逐位相同** —— 行一个不多、一个不少、顺序也没变。
167
+ *
168
+ * 此时 DOM 已经是正确顺序(每次更新末尾的重排保证了「DOM 顺序 === entries 顺序」),
169
+ * 于是建表(Map)、查重(Set)、差集(retained Set)、重排(seq 数组 + LIS)**全都不需要**,
170
+ * 只同步每行的 item 与 index。这些记账是逐行的,200 行一次的固定成本实测约 34µs,
171
+ * 而这一轮真正需要做的 DOM 写只有 0.2µs(见 scripts/bench)。
172
+ *
173
+ * 扫描本身是 O(n) 的两次比较(key 与是否需重建),比省掉的记账便宜一个数量级。
174
+ * 任何一行需要重建(原始类型项的值变了 —— 那种行的视图是静态捕获的)就退回常规路径。
175
+ */
176
+ if (keys !== null && entries.length === items.length) {
177
+ let fast = true
178
+ for (let index = 0; index < keys.length; index++) {
179
+ if (!Object.is(entries[index].key, keys[index])) {
180
+ fast = false
181
+ break
182
+ }
183
+ const item = items[index]
184
+ if (isPrimitiveItem(item) && !Object.is(entries[index].current, item)) {
185
+ fast = false
186
+ break
187
+ }
188
+ }
189
+ if (fast) {
190
+ for (let index = 0; index < items.length; index++) {
191
+ const entry = entries[index]
192
+ entry.index = index
193
+ const item = items[index]
194
+ if (!Object.is(entry.current, item)) {
195
+ // item 信号写入在值相同时会被 Object.is 短路,所以这里只对真正变了的行动手
196
+ entry.item.value = item
197
+ entry.current = item
198
+ }
199
+ }
200
+ return
201
+ }
202
+ }
203
+
164
204
  const nextEntries = keys
165
205
  ? reconcileKeyed(items, keys, entries, renderItem)
166
206
  : reconcileIndexed(items, entries, renderItem)
@@ -283,7 +323,10 @@ function reconcileKeyed<T>(
283
323
  entries: Array<ListEntry<T>>,
284
324
  renderItem: (item: T, index: number) => VobsNode
285
325
  ): Array<ListEntry<T>> {
286
- const previous = new Map(entries.map(entry => [entry.key, entry]))
326
+ // 逐项建表,不用 entries.map(...):后者会先造一个等长的「键值对数组」中间产物,
327
+ // 200 行就是 200 个元组对象的分配 —— 每次更新都白付一次。
328
+ const previous = new Map<unknown, ListEntry<T>>()
329
+ for (const entry of entries) previous.set(entry.key, entry)
287
330
  const seen = new Set<unknown>()
288
331
  const nextEntries: Array<ListEntry<T>> = []
289
332
 
@@ -297,13 +340,13 @@ function reconcileKeyed<T>(
297
340
  const entry = previous.get(key)
298
341
  if (entry) {
299
342
  previous.delete(key)
300
- if (isPrimitiveItem(item) && !Object.is(entry.value, item)) {
343
+ if (isPrimitiveItem(item) && !Object.is(entry.current, item)) {
301
344
  // 原始类型项在编译产物中被静态捕获,无法通过 item 信号刷新视图:值变化时必须重建行。
302
345
  nextEntries.push(createListEntry(item, index, key, renderItem))
303
346
  continue
304
347
  }
305
348
  entry.item.value = item
306
- entry.value = item
349
+ entry.current = item
307
350
  nextEntries.push(entry)
308
351
  continue
309
352
  }
@@ -322,14 +365,14 @@ function reconcileIndexed<T>(
322
365
  for (let index = 0; index < items.length; index++) {
323
366
  const item = items[index]
324
367
  const entry = entries[index]
325
- if (entry && isPrimitiveItem(item) && !Object.is(entry.value, item)) {
368
+ if (entry && isPrimitiveItem(item) && !Object.is(entry.current, item)) {
326
369
  // 原始类型项被编译产物静态捕获,值变化时必须重建行;旧行由主循环统一 dispose。
327
370
  nextEntries.push(createListEntry(item, index, index, renderItem))
328
371
  continue
329
372
  }
330
373
  if (entry) {
331
374
  entry.item.value = item
332
- entry.value = item
375
+ entry.current = item
333
376
  nextEntries.push(entry)
334
377
  continue
335
378
  }
@@ -359,7 +402,7 @@ function createListEntry<T>(
359
402
  node = viewOwner.run(() => renderItem(toReactiveItem(itemSignal, item), index))
360
403
  })
361
404
  associateNodeOwner(node, viewOwner)
362
- return { key, node, owner, viewOwner, item: itemSignal, value: item, index }
405
+ return { key, node, owner, viewOwner, item: itemSignal, current: item, index }
363
406
  }
364
407
 
365
408
  function refreshListEntry<T>(
@@ -374,7 +417,7 @@ function refreshListEntry<T>(
374
417
  entry.owner.run(() => {
375
418
  entry.viewOwner = createOwner()
376
419
  entry.node = entry.viewOwner.run(() => renderItem(
377
- toReactiveItem(entry.item, entry.value),
420
+ toReactiveItem(entry.item, entry.current),
378
421
  index
379
422
  ))
380
423
  })
@@ -388,9 +431,8 @@ function toReactiveItem<T>(item: Signal<T>, initialValue: T): T {
388
431
  return initialValue
389
432
  }
390
433
 
391
- // 代理目标使用创建时的原始对象:避免在列表 effect 内读取 item 信号造成自依赖;
392
- // 属性读取始终转发到 item.value,在行内绑定 effect 中被正常追踪。
393
- return new Proxy(initialValue as object, {
434
+ // 属性读取转发到 item.value(顶替后的新条目),由 get 陷阱完成追踪;目标见 reactiveProxyTarget。
435
+ return new Proxy(reactiveProxyTarget(initialValue), {
394
436
  get(_target, property, receiver) {
395
437
  return Reflect.get(item.value as object, property, receiver)
396
438
  },
@@ -400,12 +442,47 @@ function toReactiveItem<T>(item: Signal<T>, initialValue: T): T {
400
442
  ownKeys() {
401
443
  return Reflect.ownKeys(item.value as object)
402
444
  },
403
- getOwnPropertyDescriptor(_target, property) {
404
- return Object.getOwnPropertyDescriptor(item.value as object, property)
445
+ getOwnPropertyDescriptor(target, property) {
446
+ const descriptor = Object.getOwnPropertyDescriptor(item.value as object, property)
447
+ if (!descriptor) return undefined
448
+ // 目标上已有的自有属性不能改报"可配置性"(数组的 length 就不可配置),否则违反不变量
449
+ const own = Object.getOwnPropertyDescriptor(target, property)
450
+ return { ...descriptor, configurable: own ? own.configurable : true }
405
451
  }
406
452
  }) as T
407
453
  }
408
454
 
455
+ /**
456
+ * 选择代理目标。
457
+ *
458
+ * 目标**不能**带"不可配置"的自有属性:代理把属性读取转发到 `item.value`(同 key 顶替后的
459
+ * 新条目),而 Proxy 不变量要求 get / getOwnPropertyDescriptor / ownKeys 与目标一致。实测
460
+ * `Object.freeze` 过的条目(@vobs/notification 每条通知都冻结)直接抛:
461
+ *
462
+ * TypeError: 'get' on proxy: property 'content' is a read-only and non-configurable data
463
+ * property on the proxy target but the proxy did not return its actual value
464
+ *
465
+ * 于是"冻结条目的行原地刷新"这条路径根本不可能成立(首轮渲染恰好同值,把问题掩盖了)。
466
+ *
467
+ * 安全的条目(可扩展 + 自有属性全部可配置:普通字面量、DOM 包装器等)照旧用条目自身当目标 ——
468
+ * jsdom 会把 sameObjectCaches 之类的 Symbol 缓存写在包装器上,写与读必须落在同一个对象上,
469
+ * 换目标会让 `element.classList` 直接抛 TypeError。冻结/密封/defineProperty 过的条目改用
470
+ * 同原型的空对象(数组用空数组,保住 Array.isArray)。
471
+ */
472
+ function reactiveProxyTarget(value: object): object {
473
+ if (Object.isExtensible(value)) {
474
+ let allConfigurable = true
475
+ for (const key of Reflect.ownKeys(value)) {
476
+ if (Object.getOwnPropertyDescriptor(value, key)?.configurable === false) {
477
+ allConfigurable = false
478
+ break
479
+ }
480
+ }
481
+ if (allConfigurable) return value
482
+ }
483
+ return Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value) as object | null)
484
+ }
485
+
409
486
  function disposeEntry(parent: Node, entry: ListEntry<unknown>): void {
410
487
  removeChild(parent, entry.node)
411
488
  entry.owner.dispose()
package/src/error.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { VobsError, formatVobsError, normalizeVobsError } from './error'
2
+ import { VobsError, formatVobsError, isVobsError, normalizeVobsError } from './error'
3
3
 
4
4
  describe('Vobs error protocol', () => {
5
5
  it('normalizes unknown throws with a stable code', () => {
@@ -25,4 +25,79 @@ describe('Vobs error protocol', () => {
25
25
  expect(output).toContain('Fix: Guard the value before reading it.')
26
26
  expect(formatVobsError(error, { environment: 'production' })).toBe('[Vobs VOBS_R001] Component render failed')
27
27
  })
28
+
29
+ /*
30
+ * 诊断系统自己丢诊断 —— 三处都是这样。
31
+ * `isVobsError` 要求 code + message + layer 三者齐全,于是调用方递一个
32
+ * `{ code, message }` 会被判为「不是 VobsError」,掉进 String(value) 兜底,
33
+ * 输出 `[object Object]`,消息彻底消失。
34
+ */
35
+ describe('不丢消息', () => {
36
+ it('code + message 的结构化对象保留消息', () => {
37
+ const error = normalizeVobsError({ code: 'VOBS_H001', message: 'boom' })
38
+ expect(error.message).toBe('boom')
39
+ expect(error.message).not.toContain('[object')
40
+ expect(error.code).toBe('VOBS_H001')
41
+ expect(error.layer).toBe('runtime')
42
+ expect(formatVobsError(error).split('\n')[0]).toBe('[Vobs Error] boom')
43
+ })
44
+
45
+ it('只有 message 的对象也保留消息', () => {
46
+ expect(normalizeVobsError({ message: 'just a message' }).message).toBe('just a message')
47
+ })
48
+
49
+ it('既不是 Error 也没有 message 的值仍然给出可读内容', () => {
50
+ expect(normalizeVobsError({ some: 'object' }).message).toContain('object')
51
+ expect(normalizeVobsError(null).message).toBe('null')
52
+ })
53
+
54
+ it('isVobsError 不要求 layer 齐全', () => {
55
+ expect(isVobsError({ code: 'X', message: 'y' })).toBe(true)
56
+ expect(isVobsError({ code: 'X' })).toBe(false)
57
+ expect(isVobsError('not an error')).toBe(false)
58
+ })
59
+ })
60
+
61
+ /*
62
+ * tsup `splitting: false` 让 error.ts 同时进 dist/index.js 与 dist/error.js,
63
+ * 两份类互不 instanceof。跨入口传进来的实例必须被识别并复制成本地实例,
64
+ * 否则下游的 instanceof 与格式化都会走偏。
65
+ */
66
+ describe('跨打包副本的类身份', () => {
67
+ it('结构识别把外来实例复制成本地实例', () => {
68
+ // 模拟「另一个副本」:名字与字段都像 VobsError,但不 instanceof 本地类
69
+ const foreign = Object.assign(new Error('来自另一个副本'), {
70
+ name: 'VobsError',
71
+ code: 'VOBS_F001',
72
+ severity: 'error',
73
+ layer: 'runtime'
74
+ })
75
+ expect(foreign).not.toBeInstanceOf(VobsError)
76
+
77
+ const adopted = normalizeVobsError(foreign)
78
+ expect(adopted).toBeInstanceOf(VobsError)
79
+ expect(adopted.code).toBe('VOBS_F001')
80
+ expect(adopted.message).toBe('来自另一个副本')
81
+ expect(adopted.cause).toBe(foreign)
82
+ })
83
+
84
+ it('本地实例原样返回,普通 Error 保持同一性', () => {
85
+ const local = new VobsError({ code: 'VOBS_L', message: 'local' })
86
+ expect(normalizeVobsError(local)).toBe(local)
87
+
88
+ const plain = new TypeError('plain')
89
+ const normalized = normalizeVobsError(plain, { code: 'VOBS_P' })
90
+ expect(normalized).toBe(plain)
91
+ expect((plain as { code?: string }).code).toBe('VOBS_P')
92
+ })
93
+
94
+ it('冻结的 Error 上挂不上元数据时复制成本地实例,而不是丢掉 code', () => {
95
+ const frozen = Object.freeze(new Error('frozen boom'))
96
+ const normalized = normalizeVobsError(frozen, { code: 'VOBS_FROZEN' })
97
+
98
+ expect(normalized.code).toBe('VOBS_FROZEN')
99
+ expect(normalized.message).toBe('frozen boom')
100
+ expect(normalized.cause).toBe(frozen)
101
+ })
102
+ })
28
103
  })
package/src/error.ts CHANGED
@@ -69,17 +69,77 @@ export function createVobsError(options: VobsErrorOptions): VobsError {
69
69
  return new VobsError(options)
70
70
  }
71
71
 
72
+ /**
73
+ * 结构化识别(不依赖 `instanceof`)。
74
+ *
75
+ * **不能要求 `layer` 也齐全** —— 原来要求 code + message + layer 三者都是字符串,
76
+ * 于是调用方递一个 `{ code, message }` 就会被判为「不是 VobsError」,掉进最后的
77
+ * `String(value)` 兜底,输出 `[object Object]`:诊断系统自己把诊断丢了。
78
+ * layer 缺失在下面构造时会补上默认值,没有理由在识别阶段就否掉。
79
+ */
72
80
  export function isVobsError(value: unknown): value is VobsError {
73
81
  return value instanceof VobsError
74
82
  || Boolean(value && typeof value === 'object'
75
83
  && typeof (value as { code?: unknown }).code === 'string'
76
- && typeof (value as { message?: unknown }).message === 'string'
77
- && typeof (value as { layer?: unknown }).layer === 'string')
84
+ && typeof (value as { message?: unknown }).message === 'string')
85
+ }
86
+
87
+ /**
88
+ * 别的打包副本造的 VobsError。
89
+ *
90
+ * tsup `splitting: false` 让 error.ts 同时进 `dist/index.js` 与 `dist/error.js`,
91
+ * 两份类互不 `instanceof`。字段齐全,只是身份不同 —— 识别出来复制成本地实例。
92
+ */
93
+ function isForeignVobsError(value: unknown): value is Error & VobsErrorOptions {
94
+ return value instanceof Error
95
+ && value.name === 'VobsError'
96
+ && typeof (value as { code?: unknown }).code === 'string'
97
+ }
98
+
99
+ /** 兜底取消息:至少要能读出对象上的 message,别再输出 `[object Object]`。 */
100
+ function describeUnknown(value: unknown): string {
101
+ if (value === null || value === undefined) return String(value)
102
+ if (typeof value === 'string') return value
103
+ if (typeof value === 'object') {
104
+ const message = (value as { message?: unknown }).message
105
+ if (typeof message === 'string' && message !== '') return message
106
+ try {
107
+ return JSON.stringify(value) ?? String(value)
108
+ } catch {
109
+ return String(value)
110
+ }
111
+ }
112
+ return String(value)
113
+ }
114
+
115
+ /** 把任意来源的「类 VobsError」字段复制成本地实例。 */
116
+ function adoptVobsError(
117
+ value: VobsErrorOptions & { cause?: unknown },
118
+ defaults: VobsErrorDefaults
119
+ ): VobsError {
120
+ return new VobsError({
121
+ code: value.code,
122
+ message: value.message,
123
+ severity: value.severity ?? defaults.severity,
124
+ layer: value.layer ?? defaults.layer,
125
+ cause: value.cause ?? (value instanceof Error ? value : undefined),
126
+ fix: value.fix ?? defaults.fix,
127
+ location: value.location,
128
+ trace: value.trace,
129
+ example: value.example,
130
+ docs: value.docs,
131
+ codeFrame: value.codeFrame
132
+ })
78
133
  }
79
134
 
80
135
  /** Convert thrown strings and third-party errors without losing their message. */
81
136
  export function normalizeVobsError(value: unknown, defaults: VobsErrorDefaults = {}): VobsError {
137
+ // 本地实例:原样返回,保持同一性与已有 stack
82
138
  if (value instanceof VobsError) return value
139
+
140
+ // 别的副本造的:复制成本地实例,下游 instanceof 才成立
141
+ if (isForeignVobsError(value)) return adoptVobsError(value, defaults)
142
+
83
143
  if (value instanceof Error) {
84
144
  // Preserve the original Error identity so boundaries and DevTools can
85
145
  // correlate event/effect phases without creating duplicate diagnostics.
@@ -89,7 +149,24 @@ export function normalizeVobsError(value: unknown, defaults: VobsErrorDefaults =
89
149
  readonly vobsSource?: unknown
90
150
  }
91
151
  const code = defaults.code ?? (typeof metadata.vobsCode === 'string' ? metadata.vobsCode : undefined)
92
- if (code) defineErrorMetadata(value, 'code', code)
152
+ if (code) {
153
+ defineErrorMetadata(value, 'code', code)
154
+ /*
155
+ * 冻结 / 不可扩展的第三方 Error 上挂不上元数据。原来这里静默吞掉,
156
+ * 于是 normalize 的结果 code 是 undefined(打印成 VOBS_UNKNOWN)。
157
+ * 挂不上就复制成本地实例,别把 code 丢了。
158
+ */
159
+ if ((value as { code?: unknown }).code !== code) {
160
+ return new VobsError({
161
+ code,
162
+ message: value.message,
163
+ severity: defaults.severity ?? 'error',
164
+ layer: defaults.layer ?? 'runtime',
165
+ cause: value,
166
+ fix: defaults.fix ?? (typeof metadata.vobsHint === 'string' ? metadata.vobsHint : undefined)
167
+ })
168
+ }
169
+ }
93
170
  defineErrorMetadata(value, 'severity', defaults.severity ?? 'error')
94
171
  defineErrorMetadata(value, 'layer', defaults.layer ?? 'runtime')
95
172
  const fix = defaults.fix ?? (typeof metadata.vobsHint === 'string' ? metadata.vobsHint : undefined)
@@ -103,26 +180,13 @@ export function normalizeVobsError(value: unknown, defaults: VobsErrorDefaults =
103
180
  }
104
181
  return value as VobsError
105
182
  }
106
- if (isVobsError(value)) {
107
- const candidate = value as VobsErrorOptions & { severity?: VobsErrorSeverity; layer?: VobsErrorLayer }
108
- return new VobsError({
109
- code: candidate.code,
110
- message: candidate.message,
111
- severity: candidate.severity ?? defaults.severity,
112
- layer: candidate.layer ?? defaults.layer,
113
- cause: candidate.cause,
114
- fix: candidate.fix ?? defaults.fix,
115
- location: candidate.location,
116
- trace: candidate.trace,
117
- example: candidate.example,
118
- docs: candidate.docs,
119
- codeFrame: candidate.codeFrame
120
- })
121
- }
122
- const message = String(value)
183
+
184
+ // 结构化对象(可能缺 layer,甚至缺 severity):补齐后构造成本地 VobsError
185
+ if (isVobsError(value)) return adoptVobsError(value as VobsErrorOptions, defaults)
186
+
123
187
  return new VobsError({
124
188
  code: defaults.code ?? 'VOBS_UNKNOWN',
125
- message,
189
+ message: describeUnknown(value),
126
190
  severity: defaults.severity ?? 'error',
127
191
  layer: defaults.layer ?? 'runtime',
128
192
  cause: undefined,
@@ -135,7 +199,8 @@ function defineErrorMetadata(target: Error, key: string, value: unknown): void {
135
199
  try {
136
200
  Object.defineProperty(target, key, { configurable: true, enumerable: false, value, writable: true })
137
201
  } catch {
138
- // Frozen third-party errors still retain their original message and stack.
202
+ // 冻结 / 不可扩展的第三方 Error:元数据挂不上。调用方会用「写完再读回」判断
203
+ // 是否成功,失败时改成复制成本地实例,而不是静默丢掉 code。
139
204
  }
140
205
  }
141
206
 
@@ -0,0 +1,128 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from 'vitest'
3
+ import { createOwner } from '@vobs/reactivity'
4
+ import { createDOMRenderer, setRenderer } from '@vobs/vobs'
5
+ import { addEventListener, bindSpreadProps, createElement, removeEventListener, setStaticProps } from './index'
6
+
7
+ setRenderer(createDOMRenderer())
8
+
9
+ const flush = async () => { await Promise.resolve(); await Promise.resolve() }
10
+
11
+ /*
12
+ * `Owner.cleanups` 是**只 push 的数组**(reactivity/src/owner.ts:57,84)。
13
+ * 事件重绑此前在**每次** `addEventListener` 里无条件 `owner.onDispose(...)`,
14
+ * 于是换一次 handler 就多一条永不执行的清理项,旧 handler 闭包被一并扣住。
15
+ *
16
+ * 实测(.artifacts/reports/runtime.supplement.md 缺点 4 与既有报告缺点 1,两轮独立复现):
17
+ * 500 次替换 → `cleanups.length === 500`;一轮 `{...props}` 换 onClick → 202。
18
+ * 组件 effect 每次重跑都会重绑事件,所以这是热路径上的**无界增长**
19
+ * (长寿命页面里等价于内存泄漏)。
20
+ *
21
+ * 判据用 `owner.mark()` 前后的 cleanups 计数差 —— `mark()` 是 Owner 公开接口,
22
+ * 不依赖 `cleanups` 是私有字段。
23
+ */
24
+ describe('事件重绑不会让 Owner.cleanups 无界增长', () => {
25
+ it('同一 (node,event) 换 200 次 handler,清理槽只涨 1', () => {
26
+ const owner = createOwner()
27
+ const el = createElement('button')
28
+ owner.run(() => {
29
+ const before = owner.mark().cleanups
30
+ for (let index = 0; index < 200; index += 1) {
31
+ addEventListener(el, 'click', () => { void index })
32
+ }
33
+ const after = owner.mark().cleanups
34
+ // 修复前这里是 200
35
+ expect(after - before).toBe(1)
36
+ })
37
+ })
38
+
39
+ it('不同事件名各占一个槽(不是共用)', () => {
40
+ const owner = createOwner()
41
+ const el = createElement('button')
42
+ owner.run(() => {
43
+ const before = owner.mark().cleanups
44
+ addEventListener(el, 'click', () => {})
45
+ addEventListener(el, 'keydown', () => {})
46
+ addEventListener(el, 'focus', () => {})
47
+ expect(owner.mark().cleanups - before).toBe(3)
48
+ })
49
+ })
50
+
51
+ it('同一事件名在不同节点上各占一个槽', () => {
52
+ const owner = createOwner()
53
+ const a = createElement('button')
54
+ const b = createElement('button')
55
+ owner.run(() => {
56
+ const before = owner.mark().cleanups
57
+ addEventListener(a, 'click', () => {})
58
+ addEventListener(b, 'click', () => {})
59
+ // 同一事件名但节点不同:必须各占一槽(用 per-node 数字 id 拼 key 就是为了这个)
60
+ expect(owner.mark().cleanups - before).toBe(2)
61
+ })
62
+ })
63
+
64
+ it('{...props} 反复重绑 onClick 200 轮,清理槽不随重绑增长', async () => {
65
+ const owner = createOwner()
66
+ let handler = (): void => {}
67
+ await owner.run(async () => {
68
+ const el = createElement('button')
69
+ const before = owner.mark().cleanups
70
+ bindSpreadProps(el, () => ({ onClick: handler }))
71
+ // 建立一次之后的基线:含绑定自己的 effect 清理 + 事件槽各 1
72
+ const baseline = owner.mark().cleanups
73
+
74
+ for (let index = 0; index < 200; index += 1) {
75
+ handler = () => { void index }
76
+ await flush()
77
+ }
78
+
79
+ const after = owner.mark().cleanups
80
+ // 判据是"重绑 200 轮**没有增长**"(而不是某个绝对数):
81
+ // 修复前每轮各 +1 → 这里会是 202 级别的差;现在必须为 0。
82
+ expect(after - baseline).toBe(0)
83
+ // 且建立阶段只多了一小段固定开销(effect 清理 + 事件槽),不是 200
84
+ expect(baseline - before).toBeLessThanOrEqual(3)
85
+ })
86
+ })
87
+
88
+ it('Owner 销毁时仍能摘掉**最新**那个监听(槽没被换绑弄失效)', () => {
89
+ const owner = createOwner()
90
+ const el = createElement('button')
91
+ const hits: string[] = []
92
+ owner.run(() => {
93
+ addEventListener(el, 'click', () => hits.push('old'))
94
+ addEventListener(el, 'click', () => hits.push('new'))
95
+ })
96
+ el.dispatchEvent(new MouseEvent('click', { bubbles: true }))
97
+ expect(hits).toEqual(['new'])
98
+
99
+ owner.dispose()
100
+ el.dispatchEvent(new MouseEvent('click', { bubbles: true }))
101
+ // 销毁后必须彻底解绑 —— 槽里存的要是最新 handle,不能指向已摘掉的旧 handle
102
+ expect(hits).toEqual(['new'])
103
+ })
104
+
105
+ it('removeEventListener 之后再重新挂,仍然只占一个槽', () => {
106
+ const owner = createOwner()
107
+ const el = createElement('button')
108
+ owner.run(() => {
109
+ const before = owner.mark().cleanups
110
+ const first = (): void => {}
111
+ addEventListener(el, 'click', first)
112
+ removeEventListener(el, 'click', first)
113
+ addEventListener(el, 'click', () => {})
114
+ // 摘掉再挂:槽被覆盖,不再新增
115
+ expect(owner.mark().cleanups - before).toBe(1)
116
+ })
117
+ })
118
+
119
+ it('setStaticProps 的事件不占清理槽(静态事件不走 addEventListener)', () => {
120
+ const owner = createOwner()
121
+ const el = createElement('button')
122
+ owner.run(() => {
123
+ const before = owner.mark().cleanups
124
+ setStaticProps(el, { onClick: () => {} })
125
+ expect(owner.mark().cleanups - before).toBe(0)
126
+ })
127
+ })
128
+ })
@@ -11,12 +11,25 @@ describe('event binding dedupe', () => {
11
11
  const node = createElement('button')
12
12
  let first = 0
13
13
  let second = 0
14
- addEventListener(node, 'click', () => { first++ })
15
- addEventListener(node, 'click', () => { second++ })
14
+ const firstHandler = (): void => { first++ }
15
+ const secondHandler = (): void => { second++ }
16
+ addEventListener(node, 'click', firstHandler)
17
+ addEventListener(node, 'click', secondHandler)
16
18
  node.dispatchEvent(new MouseEvent('click'))
17
19
  expect(first).toBe(0)
18
20
  expect(second).toBe(1)
19
- removeEventListener(node, 'click', (() => undefined) as EventListener)
21
+ /*
22
+ * 这里必须传**当前绑定的那个** handler。
23
+ *
24
+ * 原来传的是一个无关的 `() => undefined`,并期望仍然摘除 —— 那等于把
25
+ * "忽略 handler 身份"这个行为编成了契约。而 DOM 的 `removeEventListener` 语义是
26
+ * 只有 `(type, listener, capture)` 全一致才摘;用另一个 listener 调用是 **no-op**。
27
+ *
28
+ * 真实调用方(`ops.ts:339`/`:368`)传的正是准确的 `previousHandler`,
29
+ * 所以按真实用法断言;"传错 handler 不得误摘"由
30
+ * `remove-listener-identity.test.ts` 专门锁住。
31
+ */
32
+ removeEventListener(node, 'click', secondHandler)
20
33
  node.dispatchEvent(new MouseEvent('click'))
21
34
  expect(second).toBe(1)
22
35
  })
package/src/hmr.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { VobsNode } from './fragment'
2
+ import { formatVobsError } from './error'
2
3
 
3
4
  export type HmrComponent<Props extends object = Record<string, unknown>> =
4
5
  (props: Props) => VobsNode
@@ -76,8 +77,13 @@ export function updateHmrModule(moduleId: string, nextModule: Record<string, unk
76
77
  for (const instance of instances) {
77
78
  try {
78
79
  instance.refresh()
79
- } catch {
80
- // HMR failures remain application errors on the next normal render.
80
+ } catch (error) {
81
+ /*
82
+ * 静默吞掉会让开发者以为热更新成功了,而屏幕上是旧的 —— 最容易被当成
83
+ * 「改了没生效」查半天。刷新失败本身不冒泡(下一次正常渲染会再抛),
84
+ * 但必须留下记录。
85
+ */
86
+ console.error(formatVobsError(error, { includeStack: true }))
81
87
  }
82
88
  }
83
89
  }
package/src/index.ts CHANGED
@@ -15,7 +15,8 @@ export type {
15
15
  RuntimeDomMutation,
16
16
  RuntimeDomMutationOperation,
17
17
  RuntimeErrorEvent,
18
- RuntimeHydrationMismatch
18
+ RuntimeHydrationMismatch,
19
+ RuntimeProvisionalText
19
20
  } from './debug'
20
21
  export { createFragment, isVobsFragment } from './fragment'
21
22
  export type { FragmentFactory, VobsFragment, VobsNode } from './fragment'
@@ -24,6 +25,7 @@ export {
24
25
  getRenderer,
25
26
  createText,
26
27
  createElement,
28
+ createSvgElement,
27
29
  createComment,
28
30
  insertBefore,
29
31
  removeChild,
@@ -31,6 +33,8 @@ export {
31
33
  setProperty,
32
34
  setAttribute,
33
35
  spreadProps,
36
+ bindSpreadProps,
37
+ removeAttribute,
34
38
  setStaticProps,
35
39
  addEventListener,
36
40
  removeEventListener,
@@ -81,3 +85,11 @@ export { insertAsyncBoundary, AsyncBoundary } from './async-boundary'
81
85
  export type { AsyncBoundaryFallback, AsyncBoundaryOptions, AsyncBoundaryProps, AsyncBoundaryView } from './async-boundary'
82
86
  export { insertProfiler, Profiler } from './profiler'
83
87
  export type { ProfilerOptions, ProfilerProps, ProfilerRenderInfo } from './profiler'
88
+
89
+ export { Show } from './show'
90
+ export type { ShowProps } from './show'
91
+
92
+ export { applyClassList, parseClassList } from './ops'
93
+
94
+ export { ClientOnly } from './client-only'
95
+ export type { ClientOnlyProps } from './client-only'