@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.
- package/README.md +1 -1
- package/dist/async-boundary.cjs +14 -4
- package/dist/async-boundary.cjs.map +1 -1
- package/dist/async-boundary.js +17 -7
- package/dist/async-boundary.js.map +1 -1
- package/dist/bind.cjs +43 -3
- package/dist/bind.cjs.map +1 -1
- package/dist/bind.js +48 -8
- package/dist/bind.js.map +1 -1
- package/dist/boundary.cjs +159 -84
- package/dist/boundary.cjs.map +1 -1
- package/dist/boundary.js +162 -87
- package/dist/boundary.js.map +1 -1
- package/dist/client-only.cjs +323 -0
- package/dist/client-only.cjs.map +1 -0
- package/dist/client-only.d.cts +42 -0
- package/dist/client-only.d.ts +42 -0
- package/dist/client-only.js +299 -0
- package/dist/client-only.js.map +1 -0
- package/dist/debug.cjs.map +1 -1
- package/dist/debug.d.cts +12 -2
- package/dist/debug.d.ts +12 -2
- package/dist/debug.js.map +1 -1
- package/dist/dom-events.cjs +46 -0
- package/dist/dom-events.cjs.map +1 -0
- package/dist/dom-events.d.cts +23 -0
- package/dist/dom-events.d.ts +23 -0
- package/dist/dom-events.js +21 -0
- package/dist/dom-events.js.map +1 -0
- package/dist/dom-props.cjs +154 -0
- package/dist/dom-props.cjs.map +1 -0
- package/dist/dom-props.d.cts +21 -0
- package/dist/dom-props.d.ts +21 -0
- package/dist/dom-props.js +128 -0
- package/dist/dom-props.js.map +1 -0
- package/dist/dynamic.cjs +68 -14
- package/dist/dynamic.cjs.map +1 -1
- package/dist/dynamic.js +73 -19
- package/dist/dynamic.js.map +1 -1
- package/dist/error-boundary.cjs +159 -84
- package/dist/error-boundary.cjs.map +1 -1
- package/dist/error-boundary.js +162 -87
- package/dist/error-boundary.js.map +1 -1
- package/dist/error.cjs +52 -20
- package/dist/error.cjs.map +1 -1
- package/dist/error.d.cts +8 -0
- package/dist/error.d.ts +8 -0
- package/dist/error.js +52 -20
- package/dist/error.js.map +1 -1
- package/dist/fragment.cjs.map +1 -1
- package/dist/fragment.js +1 -1
- package/dist/fragment.js.map +1 -1
- package/dist/hmr.cjs +137 -1
- package/dist/hmr.cjs.map +1 -1
- package/dist/hmr.js +135 -1
- package/dist/hmr.js.map +1 -1
- package/dist/index.cjs +713 -236
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.js +715 -245
- package/dist/index.js.map +1 -1
- package/dist/ops.cjs +561 -107
- package/dist/ops.cjs.map +1 -1
- package/dist/ops.d.cts +49 -4
- package/dist/ops.d.ts +49 -4
- package/dist/ops.js +557 -108
- package/dist/ops.js.map +1 -1
- package/dist/profiler.cjs +14 -4
- package/dist/profiler.cjs.map +1 -1
- package/dist/profiler.js +17 -7
- package/dist/profiler.js.map +1 -1
- package/dist/ref.cjs +160 -5
- package/dist/ref.cjs.map +1 -1
- package/dist/ref.js +160 -5
- package/dist/ref.js.map +1 -1
- package/dist/renderer.cjs.map +1 -1
- package/dist/renderer.d.cts +2 -0
- package/dist/renderer.d.ts +2 -0
- package/dist/show.cjs +196 -0
- package/dist/show.cjs.map +1 -0
- package/dist/show.d.cts +54 -0
- package/dist/show.d.ts +54 -0
- package/dist/show.js +171 -0
- package/dist/show.js.map +1 -0
- package/dist/svg.cjs +102 -0
- package/dist/svg.cjs.map +1 -0
- package/dist/svg.d.cts +21 -0
- package/dist/svg.d.ts +21 -0
- package/dist/svg.js +76 -0
- package/dist/svg.js.map +1 -0
- package/package.json +20 -2
- package/src/bind.ts +30 -2
- package/src/boundaries.test.ts +54 -1
- package/src/boundary.ts +24 -8
- package/src/clear-remove.test.ts +85 -0
- package/src/client-only.test.ts +140 -0
- package/src/client-only.ts +65 -0
- package/src/component-empty-return.test.ts +123 -0
- package/src/debug.ts +12 -1
- package/src/dom-events.ts +32 -0
- package/src/dom-props-consistency.test.ts +149 -0
- package/src/dom-props.test.ts +70 -0
- package/src/dom-props.ts +107 -0
- package/src/dynamic.ts +91 -14
- package/src/error.test.ts +76 -1
- package/src/error.ts +87 -22
- package/src/event-cleanup-growth.test.ts +128 -0
- package/src/events.test.ts +16 -3
- package/src/hmr.ts +8 -2
- package/src/index.ts +13 -1
- package/src/invariants.test.ts +252 -0
- package/src/list-fast-path.test.ts +83 -0
- package/src/list-reactive-item.test.ts +77 -0
- package/src/ops.ts +419 -60
- package/src/props.test.ts +61 -1
- package/src/ref-cleanup.test.ts +144 -0
- package/src/ref.ts +57 -8
- package/src/remove-listener-identity.test.ts +97 -0
- package/src/renderer.ts +3 -1
- package/src/select-hidden-subscription.test.ts +104 -0
- package/src/show-classlist.test.ts +205 -0
- package/src/show.ts +107 -0
- package/src/spread-stable-shape.test.ts +109 -0
- package/src/spread.test.ts +86 -0
- package/src/svg.test.ts +28 -1
- 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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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.
|
|
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
|
-
//
|
|
392
|
-
|
|
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(
|
|
404
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
//
|
|
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
|
+
})
|
package/src/events.test.ts
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'
|