@vobs/runtime 1.7.7 → 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.
- 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
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/*
|
|
3
|
+
* runtime 核心不变量检查。
|
|
4
|
+
*
|
|
5
|
+
* I2 Owner 作用域 —— 节点/子树离开文档树后,它建立的 effect 必须停止运行;
|
|
6
|
+
* 结构块被替换时,被丢弃那轮的 effect 必须整体释放
|
|
7
|
+
* I4 通道语义 —— attribute 与 property 两条通道的语义必须与声明一致
|
|
8
|
+
* I3 节点身份 —— 无关更新不得重建节点
|
|
9
|
+
*
|
|
10
|
+
* 判据一律用**可观察行为**(effect 还跑不跑、DOM 变没变),不读内部字段。
|
|
11
|
+
*/
|
|
12
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
13
|
+
import { createOwner, effect, state, type Owner } from '@vobs/reactivity'
|
|
14
|
+
import { createDOMRenderer } from '@vobs/dom'
|
|
15
|
+
import { setRenderer } from '@vobs/vobs'
|
|
16
|
+
import {
|
|
17
|
+
bindAttribute,
|
|
18
|
+
bindProperty,
|
|
19
|
+
bindText,
|
|
20
|
+
clear,
|
|
21
|
+
createElement,
|
|
22
|
+
createText,
|
|
23
|
+
insertBefore,
|
|
24
|
+
insertDynamic,
|
|
25
|
+
insertDynamicValue,
|
|
26
|
+
insertList,
|
|
27
|
+
removeAttribute,
|
|
28
|
+
setAttribute,
|
|
29
|
+
setStaticProps
|
|
30
|
+
} from './index'
|
|
31
|
+
|
|
32
|
+
setRenderer(createDOMRenderer())
|
|
33
|
+
|
|
34
|
+
const flush = async (): Promise<void> => {
|
|
35
|
+
for (let i = 0; i < 4; i++) await Promise.resolve()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let liveOwners: Owner[] = []
|
|
39
|
+
const makeOwner = (): Owner => { const o = createOwner(); liveOwners.push(o); return o }
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
for (const o of liveOwners) { try { o.dispose() } catch { /* 已销毁 */ } }
|
|
42
|
+
liveOwners = []
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('I2 · 结构 effect 与内层 effect 的订阅边界', () => {
|
|
46
|
+
/*
|
|
47
|
+
* ⚠️ 记录一条**被证伪的假设**,避免以后有人重新"发现"它:
|
|
48
|
+
*
|
|
49
|
+
* 曾经怀疑 `insertDynamic` 的 `next === current` 早退会留下孤儿 block Owner
|
|
50
|
+
* (审计报告也这么写过)。实测证伪:工厂里 `effect(…)` 的调用是**同步执行**的,
|
|
51
|
+
* 此刻 current subscriber 已经是那个内层 effect 本身,所以工厂体内读到的信号
|
|
52
|
+
* **只被内层 effect 订阅**,外层结构 effect 根本不订阅它
|
|
53
|
+
* → 信号变化时外层不重跑 → 也就不会新建 block。
|
|
54
|
+
* 调试钩子确认:一次求值只产生一条 `dependencyTracked(内层 effect)`,
|
|
55
|
+
* 写入后只有内层被 invalidated。
|
|
56
|
+
*
|
|
57
|
+
* 这是 run-once 模型的正确行为,不是缺陷。下面用行为把它钉住。
|
|
58
|
+
*/
|
|
59
|
+
it('结构 effect 不得订阅"只被内层 effect 读取"的信号', async () => {
|
|
60
|
+
const owner = makeOwner()
|
|
61
|
+
const parent = document.createElement('div')
|
|
62
|
+
const stable = createElement('span')
|
|
63
|
+
const inner = state(0)
|
|
64
|
+
let factoryCalls = 0
|
|
65
|
+
let innerRuns = 0
|
|
66
|
+
|
|
67
|
+
owner.run(() => {
|
|
68
|
+
insertDynamic(parent, null, () => {
|
|
69
|
+
factoryCalls += 1
|
|
70
|
+
effect(() => { inner.value; innerRuns += 1 })
|
|
71
|
+
return stable
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
await flush()
|
|
75
|
+
expect(factoryCalls).toBe(1)
|
|
76
|
+
expect(innerRuns).toBe(1)
|
|
77
|
+
|
|
78
|
+
for (let i = 1; i <= 3; i++) { inner.value = i; await flush() }
|
|
79
|
+
|
|
80
|
+
// 内层每轮都跑;结构 effect 一次都不该重跑(否则白白重建结构块)
|
|
81
|
+
expect(innerRuns).toBe(4)
|
|
82
|
+
expect(factoryCalls).toBe(1)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('工厂**自己**读的信号必须让结构 effect 重跑(对照组)', async () => {
|
|
86
|
+
const owner = makeOwner()
|
|
87
|
+
const parent = document.createElement('div')
|
|
88
|
+
const which = state('a')
|
|
89
|
+
let factoryCalls = 0
|
|
90
|
+
|
|
91
|
+
owner.run(() => {
|
|
92
|
+
insertDynamic(parent, null, () => {
|
|
93
|
+
factoryCalls += 1
|
|
94
|
+
return createElement(which.value === 'a' ? 'span' : 'b')
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
await flush()
|
|
98
|
+
expect(factoryCalls).toBe(1)
|
|
99
|
+
which.value = 'b'
|
|
100
|
+
await flush()
|
|
101
|
+
expect(factoryCalls).toBe(2)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('insertDynamicValue 替换时,被丢弃那轮建立的 effect 必须停止', async () => {
|
|
105
|
+
const owner = makeOwner()
|
|
106
|
+
const parent = document.createElement('div')
|
|
107
|
+
const which = state(1)
|
|
108
|
+
const noise = state(0)
|
|
109
|
+
const runsByRound = [0, 0]
|
|
110
|
+
|
|
111
|
+
owner.run(() => {
|
|
112
|
+
insertDynamicValue(parent, null, () => {
|
|
113
|
+
const round = which.value - 1
|
|
114
|
+
effect(() => { noise.value; runsByRound[round] += 1 })
|
|
115
|
+
return createElement('span')
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
await flush()
|
|
119
|
+
expect(runsByRound).toEqual([1, 0])
|
|
120
|
+
|
|
121
|
+
which.value = 2 // 换成第 2 轮 → 第 1 轮的 block 必须被 dispose
|
|
122
|
+
await flush()
|
|
123
|
+
expect(runsByRound[1]).toBe(1)
|
|
124
|
+
|
|
125
|
+
// 关键判据:第 1 轮的 effect 不得再被唤醒(否则就是幽灵订阅)
|
|
126
|
+
const round1Before = runsByRound[0]
|
|
127
|
+
noise.value = 1
|
|
128
|
+
await flush()
|
|
129
|
+
expect(runsByRound[0]).toBe(round1Before)
|
|
130
|
+
expect(runsByRound[1]).toBe(2)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('I2 · 卸载必须停止该子树建立的 effect', () => {
|
|
135
|
+
it('owner.dispose() 后,子树里的 effect 不得再运行', async () => {
|
|
136
|
+
const owner = makeOwner()
|
|
137
|
+
const parent = document.createElement('div')
|
|
138
|
+
const value = state(0)
|
|
139
|
+
let runs = 0
|
|
140
|
+
owner.run(() => {
|
|
141
|
+
const el = createElement('div')
|
|
142
|
+
insertBefore(parent, el, null)
|
|
143
|
+
const text = createText('')
|
|
144
|
+
bindText(text, () => { value.value; runs += 1; return 'x' })
|
|
145
|
+
insertBefore(el, text, null)
|
|
146
|
+
})
|
|
147
|
+
await flush()
|
|
148
|
+
value.value = 1
|
|
149
|
+
await flush()
|
|
150
|
+
const before = runs
|
|
151
|
+
owner.dispose()
|
|
152
|
+
value.value = 2
|
|
153
|
+
await flush()
|
|
154
|
+
expect(runs).toBe(before)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('clear() 不得抛错,且清空容器', () => {
|
|
158
|
+
const owner = makeOwner()
|
|
159
|
+
const parent = document.createElement('div')
|
|
160
|
+
owner.run(() => {
|
|
161
|
+
insertBefore(parent, createElement('section'), null)
|
|
162
|
+
})
|
|
163
|
+
expect(parent.children.length).toBe(1)
|
|
164
|
+
expect(() => clear(parent)).not.toThrow()
|
|
165
|
+
expect(parent.children.length).toBe(0)
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
describe('I4 · attribute 与 property 两条通道的语义', () => {
|
|
170
|
+
it('attribute 的 false 不设置属性', () => {
|
|
171
|
+
const el = createElement('div')
|
|
172
|
+
setStaticProps(el, { title: false, draggable: false })
|
|
173
|
+
expect(el.hasAttribute('title')).toBe(false)
|
|
174
|
+
expect(el.hasAttribute('draggable')).toBe(false)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('property 的 false 清除 property(disabled/checked)', () => {
|
|
178
|
+
const input = createElement('input') as HTMLInputElement
|
|
179
|
+
setStaticProps(input, { disabled: true, checked: true })
|
|
180
|
+
expect(input.disabled).toBe(true)
|
|
181
|
+
expect(input.checked).toBe(true)
|
|
182
|
+
setStaticProps(input, { disabled: false, checked: false })
|
|
183
|
+
expect(input.disabled).toBe(false)
|
|
184
|
+
expect(input.checked).toBe(false)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('绑定 attribute 为 null/undefined 时不写入字面量 "undefined"', async () => {
|
|
188
|
+
const owner = makeOwner()
|
|
189
|
+
const el = createElement('input')
|
|
190
|
+
const value = state<string | undefined>(undefined)
|
|
191
|
+
owner.run(() => { bindAttribute(el, 'placeholder', () => value.value) })
|
|
192
|
+
await flush()
|
|
193
|
+
expect(el.hasAttribute('placeholder')).toBe(false)
|
|
194
|
+
value.value = 'hi'
|
|
195
|
+
await flush()
|
|
196
|
+
expect(el.getAttribute('placeholder')).toBe('hi')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('removeAttribute 真的摘掉属性', () => {
|
|
200
|
+
const el = createElement('div')
|
|
201
|
+
setAttribute(el, 'title', 'x')
|
|
202
|
+
expect(el.hasAttribute('title')).toBe(true)
|
|
203
|
+
removeAttribute(el, 'title')
|
|
204
|
+
expect(el.hasAttribute('title')).toBe(false)
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
describe('I3 · 节点身份:无关更新不得重建节点', () => {
|
|
209
|
+
it('bindProperty 只改值,不换节点', async () => {
|
|
210
|
+
const owner = makeOwner()
|
|
211
|
+
const input = createElement('input') as HTMLInputElement
|
|
212
|
+
const value = state('a')
|
|
213
|
+
owner.run(() => { bindProperty(input, 'value', () => value.value) })
|
|
214
|
+
await flush()
|
|
215
|
+
const identity = input
|
|
216
|
+
value.value = 'b'
|
|
217
|
+
await flush()
|
|
218
|
+
expect(input).toBe(identity)
|
|
219
|
+
expect(input.value).toBe('b')
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('insertList 追加时不得重建已有行(keyed)', async () => {
|
|
223
|
+
const owner = makeOwner()
|
|
224
|
+
const parent = document.createElement('div')
|
|
225
|
+
const rows = state([{ id: 'a' }, { id: 'b' }, { id: 'c' }])
|
|
226
|
+
owner.run(() => {
|
|
227
|
+
insertList(
|
|
228
|
+
parent,
|
|
229
|
+
null,
|
|
230
|
+
() => rows.value,
|
|
231
|
+
(item: { id: string }) => {
|
|
232
|
+
const el = createElement('div')
|
|
233
|
+
setAttribute(el, 'data-id', item.id)
|
|
234
|
+
return el
|
|
235
|
+
},
|
|
236
|
+
(item: { id: string }) => item.id
|
|
237
|
+
)
|
|
238
|
+
})
|
|
239
|
+
await flush()
|
|
240
|
+
const before = [...parent.children]
|
|
241
|
+
expect(before).toHaveLength(3)
|
|
242
|
+
|
|
243
|
+
rows.value = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }]
|
|
244
|
+
await flush()
|
|
245
|
+
const after = [...parent.children]
|
|
246
|
+
expect(after).toHaveLength(4)
|
|
247
|
+
// 前三行身份必须保持(只有新增行是新的)
|
|
248
|
+
expect(after[0]).toBe(before[0])
|
|
249
|
+
expect(after[1]).toBe(before[1])
|
|
250
|
+
expect(after[2]).toBe(before[2])
|
|
251
|
+
})
|
|
252
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { createDOMRenderer, setRenderer, state } from '@vobs/vobs'
|
|
4
|
+
import { bindText, createElement, createText, insertBefore, insertList, type VobsNode } from './index'
|
|
5
|
+
|
|
6
|
+
setRenderer(createDOMRenderer())
|
|
7
|
+
|
|
8
|
+
const flush = async () => { await Promise.resolve(); await Promise.resolve() }
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 列表调和里有一条「key 序列逐位相同」的快路径(跳过建表/去重/差集/LIS)。
|
|
12
|
+
* 实测 200 行只改 1 行从 34µs 降到 4.2µs。这些测试锁住它的两条边界:
|
|
13
|
+
* 该更新的行必须更新,该重排的时候**绝不能**被快路径吞掉。
|
|
14
|
+
*/
|
|
15
|
+
function mountList<T>(items: () => readonly T[], keyOf: (item: T) => unknown) {
|
|
16
|
+
const host = document.createElement('div')
|
|
17
|
+
const root = createElement('ul')
|
|
18
|
+
const app = { host }
|
|
19
|
+
insertBefore(host as unknown as Node, root, null)
|
|
20
|
+
insertList<T>(root as unknown as Node, null, items,
|
|
21
|
+
(item: T) => {
|
|
22
|
+
const li = createElement('li')
|
|
23
|
+
const text = createText('')
|
|
24
|
+
insertBefore(li, text, null)
|
|
25
|
+
bindText(text, () => String((item as { label?: unknown }).label ?? item))
|
|
26
|
+
return li
|
|
27
|
+
},
|
|
28
|
+
(item: T) => keyOf(item)) as VobsNode | undefined
|
|
29
|
+
return { host, app }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('列表快路径', () => {
|
|
33
|
+
it('key 顺序不变、只有一项内容变了:那一行更新,其余不动', async () => {
|
|
34
|
+
const rows = state([
|
|
35
|
+
{ id: 1, label: 'a' },
|
|
36
|
+
{ id: 2, label: 'b' },
|
|
37
|
+
{ id: 3, label: 'c' }
|
|
38
|
+
])
|
|
39
|
+
const { host } = mountList(() => rows.value, item => item.id)
|
|
40
|
+
await flush()
|
|
41
|
+
expect(host.textContent).toBe('abc')
|
|
42
|
+
|
|
43
|
+
rows.value = [
|
|
44
|
+
{ id: 1, label: 'a' },
|
|
45
|
+
{ id: 2, label: 'B' },
|
|
46
|
+
{ id: 3, label: 'c' }
|
|
47
|
+
]
|
|
48
|
+
await flush()
|
|
49
|
+
expect(host.textContent).toBe('aBc')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('顺序变化必须真的重排(不能被快路径吞掉)', async () => {
|
|
53
|
+
const rows = state([{ id: 1, label: 'a' }, { id: 2, label: 'b' }, { id: 3, label: 'c' }])
|
|
54
|
+
const { host } = mountList(() => rows.value, item => item.id)
|
|
55
|
+
await flush()
|
|
56
|
+
expect(host.textContent).toBe('abc')
|
|
57
|
+
|
|
58
|
+
rows.value = [{ id: 3, label: 'c' }, { id: 1, label: 'a' }, { id: 2, label: 'b' }]
|
|
59
|
+
await flush()
|
|
60
|
+
expect(host.textContent).toBe('cab')
|
|
61
|
+
|
|
62
|
+
// DOM 顺序也要真的对(不只是文本拼接结果);列表末尾有个注释锚点,不是行
|
|
63
|
+
const labels = Array.from((host.firstChild as Element).childNodes)
|
|
64
|
+
.filter(node => node.nodeType === 1)
|
|
65
|
+
.map(node => node.textContent)
|
|
66
|
+
expect(labels).toEqual(['c', 'a', 'b'])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('插入与删除仍然正常(长度不同时快路径不参与)', async () => {
|
|
70
|
+
const rows = state([{ id: 1, label: 'a' }, { id: 3, label: 'c' }])
|
|
71
|
+
const { host } = mountList(() => rows.value, item => item.id)
|
|
72
|
+
await flush()
|
|
73
|
+
expect(host.textContent).toBe('ac')
|
|
74
|
+
|
|
75
|
+
rows.value = [{ id: 1, label: 'a' }, { id: 2, label: 'b' }, { id: 3, label: 'c' }]
|
|
76
|
+
await flush()
|
|
77
|
+
expect(host.textContent).toBe('abc')
|
|
78
|
+
|
|
79
|
+
rows.value = [{ id: 2, label: 'b' }]
|
|
80
|
+
await flush()
|
|
81
|
+
expect(host.textContent).toBe('b')
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { createDOMRenderer, setRenderer, state } from '@vobs/vobs'
|
|
4
|
+
import { bindText, createElement, createText, insertBefore, insertList, type VobsNode } from './index'
|
|
5
|
+
|
|
6
|
+
setRenderer(createDOMRenderer())
|
|
7
|
+
|
|
8
|
+
const flush = async () => { await Promise.resolve(); await Promise.resolve() }
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `insertList` 的 renderItem 拿到的是"响应式条目代理":属性读取转发到 item 信号,
|
|
12
|
+
* 所以行内 effect 能读到同 key 顶替后的新条目。
|
|
13
|
+
*
|
|
14
|
+
* 这个代理原来的目标是**创建时的原始条目**。只要条目被 Object.freeze 过
|
|
15
|
+
* (@vobs/notification 每条通知都冻结),目标上就留着"不可配置、不可写"的自有属性,
|
|
16
|
+
* get 陷阱返回新值时违反 Proxy 不变量,实测直接抛:
|
|
17
|
+
*
|
|
18
|
+
* TypeError: 'get' on proxy: property 'label' is a read-only and non-configurable data
|
|
19
|
+
* property on the proxy target but the proxy did not return its actual value
|
|
20
|
+
*
|
|
21
|
+
* 于是冻结条目的行根本无法原地刷新。首轮渲染时 item.value === 目标,值相同,看不出来。
|
|
22
|
+
*/
|
|
23
|
+
describe('insertList 的响应式条目代理', () => {
|
|
24
|
+
function mount<T>(rows: { value: readonly T[] }, keyOf: (item: T) => unknown) {
|
|
25
|
+
const host = document.createElement('div')
|
|
26
|
+
const root = createElement('ul')
|
|
27
|
+
insertBefore(host as unknown as Node, root, null)
|
|
28
|
+
const captured: T[] = []
|
|
29
|
+
let node: VobsNode | null = null
|
|
30
|
+
insertList<T>(root as unknown as Node, null, () => rows.value, item => {
|
|
31
|
+
captured.push(item)
|
|
32
|
+
const li = createElement('li')
|
|
33
|
+
const text = createText('')
|
|
34
|
+
insertBefore(li, text, null)
|
|
35
|
+
// 在 effect 内读条目:顶替后必须拿到新值
|
|
36
|
+
bindText(text, () => String((item as { label?: unknown }).label ?? item))
|
|
37
|
+
node = li
|
|
38
|
+
return li
|
|
39
|
+
}, keyOf)
|
|
40
|
+
return { host, captured, getNode: () => node }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
it('冻结条目顶替后行内 effect 读到新值,且行被原地复用', async () => {
|
|
44
|
+
const rows = state<readonly { id: number; label: string }[]>([Object.freeze({ id: 1, label: 'a' })])
|
|
45
|
+
const { host, getNode, captured } = mount(rows, (item: { id: number }) => item.id)
|
|
46
|
+
await flush()
|
|
47
|
+
expect(host.textContent).toBe('a')
|
|
48
|
+
const first = getNode()
|
|
49
|
+
|
|
50
|
+
rows.value = [Object.freeze({ id: 1, label: 'b' })]
|
|
51
|
+
await flush()
|
|
52
|
+
|
|
53
|
+
expect(host.textContent).toBe('b') // 修复前这一行直接抛 TypeError
|
|
54
|
+
expect(getNode()).toBe(first) // keyed 调和:同一个 DOM 行
|
|
55
|
+
// 冻结条目的描述符不变量:Object.keys / 展开 / in 也不能抛
|
|
56
|
+
const item = captured[captured.length - 1] as { id: number; label: string }
|
|
57
|
+
expect(Object.keys(item)).toEqual(['id', 'label'])
|
|
58
|
+
expect({ ...item }).toEqual({ id: 1, label: 'b' })
|
|
59
|
+
expect('label' in item).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('未冻结条目与数组条目保持原有语义(原型 / Array.isArray / 长度)', async () => {
|
|
63
|
+
class Row {
|
|
64
|
+
constructor(readonly label: string) {}
|
|
65
|
+
}
|
|
66
|
+
const rows = state<readonly unknown[]>([new Row('x'), [1, 2, 3]])
|
|
67
|
+
const { host, captured } = mount(rows, (item: unknown) => item)
|
|
68
|
+
await flush()
|
|
69
|
+
expect(host.textContent).toBe('x1,2,3')
|
|
70
|
+
|
|
71
|
+
const row = captured[0]
|
|
72
|
+
const list = captured[1]
|
|
73
|
+
expect(row).toBeInstanceOf(Row)
|
|
74
|
+
expect(Array.isArray(list)).toBe(true)
|
|
75
|
+
expect((list as readonly number[]).length).toBe(3)
|
|
76
|
+
})
|
|
77
|
+
})
|