@vobs/runtime 1.0.0 → 1.1.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/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "LICENSE"
7
7
  ],
8
8
  "name": "@vobs/runtime",
9
- "version": "1.0.0",
9
+ "version": "1.1.0",
10
10
  "type": "module",
11
11
  "main": "src/index.ts",
12
12
  "types": "src/index.ts",
@@ -15,6 +15,6 @@
15
15
  "./error": "./src/error.ts"
16
16
  },
17
17
  "dependencies": {
18
- "@vobs/reactivity": "1.0.0"
18
+ "@vobs/reactivity": "1.1.0"
19
19
  }
20
20
  }
@@ -1,3 +1,4 @@
1
+ // @vitest-environment jsdom
1
2
  import { describe, expect, it, vi } from 'vitest'
2
3
  import { createDOMRenderer, createText, createVobs, Profiler, AsyncBoundary, state } from '@vobs/vobs'
3
4
 
@@ -226,3 +226,174 @@ describe('primitive list item updates', () => {
226
226
  })
227
227
  })
228
228
 
229
+ describe('keyed list minimal-move reconciliation', () => {
230
+ interface Row { id: number; name: string }
231
+
232
+ function setup(initial: Row[]) {
233
+ setRenderer(createDOMRenderer())
234
+ const parent = document.createElement('div')
235
+ const source = state(initial)
236
+ const rows = new Map<number, Element>()
237
+ insertList(
238
+ parent,
239
+ null,
240
+ () => source.value,
241
+ item => {
242
+ const el = document.createElement('li')
243
+ el.textContent = item.name
244
+ rows.set(item.id, el)
245
+ return el
246
+ },
247
+ row => row.id
248
+ )
249
+ const order = () =>
250
+ Array.from(parent.children)
251
+ .filter(node => node.tagName === 'LI')
252
+ .map(node => node.textContent)
253
+ return { parent, source, rows, order }
254
+ }
255
+
256
+ // name → 稳定 id 映射:重排 name 顺序 = 真正的 key 重排
257
+ // (若用数组下标当 id,重排后 key 顺序不变,keyed reconcile 正确地零移动)
258
+ const ids = new Map<string, number>()
259
+ const rows = (names: string[]): Row[] => names.map(name => {
260
+ if (!ids.has(name)) ids.set(name, ids.size)
261
+ return { id: ids.get(name)!, name }
262
+ })
263
+
264
+ it('swaps two adjacent rows and preserves row node identity', async () => {
265
+ const { parent, source, rows: rowMap, order } = setup(rows(['a', 'b', 'c']))
266
+ const nodeB = rowMap.get(1)
267
+ const nodeC = rowMap.get(2)
268
+
269
+ source.value = rows(['a', 'c', 'b'])
270
+ await new Promise(resolve => setTimeout(resolve, 0))
271
+
272
+ expect(order()).toEqual(['a', 'c', 'b'])
273
+ // 最小移动:行节点复用,不整行重建;保留行不被移除
274
+ expect(rowMap.get(1)).toBe(nodeB)
275
+ expect(rowMap.get(2)).toBe(nodeC)
276
+ expect(parent.contains(nodeB!)).toBe(true)
277
+ })
278
+
279
+ it('moves the first row to the end', async () => {
280
+ const { source, order } = setup(rows(['a', 'b', 'c', 'd']))
281
+
282
+ source.value = rows(['b', 'c', 'd', 'a'])
283
+ await new Promise(resolve => setTimeout(resolve, 0))
284
+
285
+ expect(order()).toEqual(['b', 'c', 'd', 'a'])
286
+ })
287
+
288
+ it('reverses the whole list', async () => {
289
+ const { source, order } = setup(rows(['a', 'b', 'c', 'd', 'e']))
290
+
291
+ source.value = rows(['e', 'd', 'c', 'b', 'a'])
292
+ await new Promise(resolve => setTimeout(resolve, 0))
293
+
294
+ expect(order()).toEqual(['e', 'd', 'c', 'b', 'a'])
295
+ })
296
+
297
+ it('keeps DOM untouched when order is unchanged but item objects are new', async () => {
298
+ const { source, rows: rowMap, order } = setup(rows(['a', 'b', 'c']))
299
+ const nodesBefore = [rowMap.get(0), rowMap.get(1), rowMap.get(2)]
300
+
301
+ // 全新对象、相同 key、相同顺序:应当零移动零重建
302
+ source.value = [{ id: 0, name: 'a' }, { id: 1, name: 'b' }, { id: 2, name: 'c' }]
303
+ await new Promise(resolve => setTimeout(resolve, 0))
304
+
305
+ expect(order()).toEqual(['a', 'b', 'c'])
306
+ expect([rowMap.get(0), rowMap.get(1), rowMap.get(2)]).toEqual(nodesBefore)
307
+ })
308
+
309
+ it('handles mixed remove + insert + move in one update', async () => {
310
+ const { source, order } = setup(rows(['a', 'b', 'c', 'd', 'e']))
311
+
312
+ // 移除 b、新增 f、d 前移,同时 a 挪到末尾
313
+ source.value = [
314
+ { id: 2, name: 'c' },
315
+ { id: 3, name: 'd' },
316
+ { id: 5, name: 'f' },
317
+ { id: 4, name: 'e' },
318
+ { id: 0, name: 'a' }
319
+ ]
320
+ await new Promise(resolve => setTimeout(resolve, 0))
321
+
322
+ expect(order()).toEqual(['c', 'd', 'f', 'e', 'a'])
323
+ })
324
+
325
+ it('keeps the list anchored after surrounding sibling content', async () => {
326
+ setRenderer(createDOMRenderer())
327
+ const parent = document.createElement('div')
328
+ const head = document.createElement('p')
329
+ head.textContent = 'head'
330
+ parent.appendChild(head)
331
+ const source = state(rows(['a', 'b', 'c']))
332
+ insertList(
333
+ parent,
334
+ null,
335
+ () => source.value,
336
+ item => {
337
+ const el = document.createElement('li')
338
+ el.textContent = item.name
339
+ return el
340
+ },
341
+ row => row.id
342
+ )
343
+ const tail = document.createElement('p')
344
+ tail.textContent = 'tail'
345
+ parent.appendChild(tail)
346
+
347
+ source.value = rows(['c', 'a', 'b'])
348
+ await new Promise(resolve => setTimeout(resolve, 0))
349
+
350
+ expect(parent.textContent).toBe('headcabtail')
351
+ })
352
+
353
+ it('rebuilds index-tracked rows when they move', async () => {
354
+ setRenderer(createDOMRenderer())
355
+ const parent = document.createElement('div')
356
+ const source = state(rows(['a', 'b', 'c']))
357
+ insertList(
358
+ parent,
359
+ null,
360
+ () => source.value,
361
+ (item, index) => {
362
+ const el = document.createElement('li')
363
+ el.textContent = `${item.name}:${index}`
364
+ return el
365
+ },
366
+ row => row.id
367
+ )
368
+ expect(parent.textContent).toBe('a:0b:1c:2')
369
+
370
+ source.value = rows(['c', 'a', 'b'])
371
+ await new Promise(resolve => setTimeout(resolve, 0))
372
+ // index 参与渲染的行移动时必须重建,索引值正确
373
+ expect(parent.textContent).toBe('c:0a:1b:2')
374
+ })
375
+
376
+ it('falls back to indexed reconcile when keys are missing', async () => {
377
+ setRenderer(createDOMRenderer())
378
+ const parent = document.createElement('div')
379
+ // 原始类型项:keyOf 对 'b' 返回 null → 回退 indexed 调和
380
+ const source = state(['a', 'b', 'c'])
381
+ insertList(
382
+ parent,
383
+ null,
384
+ () => source.value,
385
+ item => {
386
+ const el = document.createElement('li')
387
+ el.textContent = item
388
+ return el
389
+ },
390
+ (item, index) => (item === 'b' ? null : index)
391
+ )
392
+ expect(parent.textContent).toBe('abc')
393
+
394
+ source.value = ['x', 'y', 'z']
395
+ await new Promise(resolve => setTimeout(resolve, 0))
396
+ expect(parent.textContent).toBe('xyz')
397
+ })
398
+ })
399
+
package/src/dynamic.ts CHANGED
@@ -108,34 +108,144 @@ export function insertList<T>(
108
108
 
109
109
  effect(() => {
110
110
  const items = source()
111
- const keyed = keyOf && items.every((item, index) => keyOf(item, index) != null)
112
- const nextEntries = keyed
113
- ? reconcileKeyed(items, entries, renderItem, keyOf)
111
+
112
+ // key 只计算一次:keyed 判定与调和共用,避免 keyOf 每轮被调用两遍。
113
+ let keys: unknown[] | null = null
114
+ if (keyOf) {
115
+ keys = new Array(items.length)
116
+ let allKeyed = items.length > 0
117
+ for (let index = 0; index < items.length; index++) {
118
+ const key = keyOf(items[index], index)
119
+ if (key == null) {
120
+ allKeyed = false
121
+ break
122
+ }
123
+ keys[index] = key
124
+ }
125
+ if (!allKeyed) keys = null
126
+ }
127
+
128
+ const nextEntries = keys
129
+ ? reconcileKeyed(items, keys, entries, renderItem)
114
130
  : reconcileIndexed(items, entries, renderItem)
115
131
 
132
+ // tracksIndex 时 index 参与渲染,位置变化的行必须整体重建,新节点不在 DOM 中,
133
+ // 重排阶段强制插入;否则只同步记录的位置,节点保持原样交给重排阶段移动。
134
+ const refreshed = tracksIndex ? new Set<ListEntry<T>>() : null
116
135
  for (let index = 0; index < nextEntries.length; index++) {
117
136
  const entry = nextEntries[index]
118
- if (tracksIndex && entry && entry.index !== index) refreshListEntry(parent, entry, index, renderItem)
137
+ if (tracksIndex) {
138
+ if (entry.index !== index) {
139
+ refreshListEntry(parent, entry, index, renderItem)
140
+ refreshed!.add(entry)
141
+ }
142
+ } else {
143
+ entry.index = index
144
+ }
119
145
  }
120
146
 
147
+ const retained = new Set(nextEntries)
121
148
  for (const entry of entries) {
122
- if (!nextEntries.includes(entry)) disposeEntry(parent, entry)
149
+ if (!retained.has(entry)) disposeEntry(parent, entry)
123
150
  }
124
151
 
125
- let reference: VobsNode | null = marker
126
- for (let index = nextEntries.length - 1; index >= 0; index--) {
127
- insertBefore(parent, nextEntries[index].node, reference)
128
- reference = nextEntries[index].node
129
- }
152
+ reorderListEntries(parent, marker, entries, nextEntries, refreshed)
130
153
  entries = nextEntries
131
154
  })
132
155
  }
133
156
 
157
+ /**
158
+ * 按 nextEntries 顺序整理 DOM,但只移动必须移动的节点。
159
+ */
160
+ function reorderListEntries<T>(
161
+ parent: Node,
162
+ marker: Node,
163
+ previous: readonly ListEntry<T>[],
164
+ nextEntries: readonly ListEntry<T>[],
165
+ forceInsert: Set<ListEntry<T>> | null
166
+ ): void {
167
+ const count = nextEntries.length
168
+ if (count === 0) return
169
+
170
+ // 首次挂载(旧列表为空):全部是新节点,倒序直插即可,跳过 Map/LIS 构建。
171
+ if (previous.length === 0) {
172
+ let reference: VobsNode = marker
173
+ for (let index = count - 1; index >= 0; index--) {
174
+ const node = nextEntries[index].node
175
+ insertBefore(parent, node, reference)
176
+ reference = node
177
+ }
178
+ return
179
+ }
180
+
181
+ const oldIndexOf = new Map<ListEntry<T>, number>()
182
+ for (let index = 0; index < previous.length; index++) oldIndexOf.set(previous[index], index)
183
+
184
+ // seq[i] = 条目在旧序中的位置;新条目、重建条目与强制插入条目为 -1。
185
+ const seq: number[] = new Array(count)
186
+ for (let index = 0; index < count; index++) {
187
+ const entry = nextEntries[index]
188
+ seq[index] = forceInsert?.has(entry) ? -1 : oldIndexOf.get(entry) ?? -1
189
+ }
190
+
191
+ const keep = computeKeptByLis(seq)
192
+
193
+ let reference: VobsNode = marker
194
+ for (let index = count - 1; index >= 0; index--) {
195
+ const node = nextEntries[index].node
196
+ if (keep[index]) {
197
+ reference = node
198
+ continue
199
+ }
200
+ insertBefore(parent, node, reference)
201
+ reference = node
202
+ }
203
+ }
204
+
205
+ /**
206
+ * 严格递增子序列(LIS)成员标记,O(n log n)。
207
+ * 负值(新节点)不参与 LIS,永远视为需要移动。
208
+ */
209
+ function computeKeptByLis(seq: readonly number[]): boolean[] {
210
+ const count = seq.length
211
+ const keep = new Array<boolean>(count).fill(false)
212
+ const tailsIndex: number[] = []
213
+ const tailsValue: number[] = []
214
+ const prev = new Array<number>(count).fill(-1)
215
+
216
+ for (let i = 0; i < count; i++) {
217
+ const value = seq[i]
218
+ if (value < 0) continue
219
+ let lo = 0
220
+ let hi = tailsValue.length
221
+ while (lo < hi) {
222
+ const mid = (lo + hi) >> 1
223
+ if (tailsValue[mid] < value) lo = mid + 1
224
+ else hi = mid
225
+ }
226
+ if (lo === tailsValue.length) {
227
+ tailsValue.push(value)
228
+ tailsIndex.push(i)
229
+ } else {
230
+ tailsValue[lo] = value
231
+ tailsIndex[lo] = i
232
+ }
233
+ prev[i] = lo > 0 ? tailsIndex[lo - 1] : -1
234
+ }
235
+
236
+ let cursor = tailsIndex.length > 0 ? tailsIndex[tailsIndex.length - 1] : -1
237
+ while (cursor >= 0) {
238
+ keep[cursor] = true
239
+ cursor = prev[cursor]
240
+ }
241
+ return keep
242
+ }
243
+
134
244
  function reconcileKeyed<T>(
135
245
  items: readonly T[],
246
+ keys: readonly unknown[],
136
247
  entries: Array<ListEntry<T>>,
137
- renderItem: (item: T, index: number) => VobsNode,
138
- keyOf: (item: T, index: number) => unknown
248
+ renderItem: (item: T, index: number) => VobsNode
139
249
  ): Array<ListEntry<T>> {
140
250
  const previous = new Map(entries.map(entry => [entry.key, entry]))
141
251
  const seen = new Set<unknown>()
@@ -143,13 +253,13 @@ function reconcileKeyed<T>(
143
253
 
144
254
  for (let index = 0; index < items.length; index++) {
145
255
  const item = items[index]
146
- const key = keyOf(item, index)
256
+ const key = keys[index]
147
257
  if (seen.has(key)) {
148
258
  console.warn(`Vobs: 检测到重复的列表 key: ${String(key)}`)
149
259
  }
150
260
  seen.add(key)
151
- if (previous.has(key)) {
152
- const entry = previous.get(key)!
261
+ const entry = previous.get(key)
262
+ if (entry) {
153
263
  previous.delete(key)
154
264
  if (isPrimitiveItem(item) && !Object.is(entry.value, item)) {
155
265
  // 原始类型项在编译产物中被静态捕获,无法通过 item 信号刷新视图:值变化时必须重建行。
package/src/hmr.test.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // @vitest-environment jsdom
1
2
  import { describe, expect, it } from 'vitest'
2
3
  import {
3
4
  createHmrStateStore,
package/src/index.ts CHANGED
@@ -45,6 +45,7 @@ export { ref, setRef } from './ref'
45
45
  export type { Ref, RefTarget } from './ref'
46
46
  export { insertDynamic, insertDynamicValue, insertList, normalizeDynamicChild } from './dynamic'
47
47
  export type { DynamicChild, NodeFactory } from './dynamic'
48
+ export { createTemplate, cloneTemplate } from './template'
48
49
  export type { VobsLocatedError, VobsSourceLocation } from './ops'
49
50
  export {
50
51
  VobsError,
package/src/ref.test.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // @vitest-environment jsdom
1
2
  import { describe, expect, it } from 'vitest'
2
3
  import { createDOMRenderer, createVobs } from '@vobs/vobs'
3
4
  import { createElement, createText, insertBefore, ref, setRef } from './index'
@@ -0,0 +1,24 @@
1
+ // 编译器静态模板提升的运行时支撑:
2
+ // 编译期把完全静态的 JSX 子树序列化为 HTML 字符串,模块加载时解析一次,
3
+ // 运行时通过 cloneNode 复制,替代逐个 createElement + setStaticProps + insertBefore。
4
+ // 仅适用于 DOM 渲染器(模板本质是 HTML),自定义渲染器场景由编译产物不使用该优化兜底。
5
+
6
+ const templateCache = new Map<string, HTMLTemplateElement>()
7
+
8
+ /** Parse a static template once; called at module load for each hoisted template. */
9
+ export function createTemplate(html: string): HTMLTemplateElement {
10
+ let template = templateCache.get(html)
11
+ if (!template) {
12
+ template = document.createElement('template')
13
+ template.innerHTML = html
14
+ templateCache.set(html, template)
15
+ }
16
+ return template
17
+ }
18
+
19
+ /** Clone a hoisted template for one mount; the compiled HTML always has a single root element. */
20
+ export function cloneTemplate(template: HTMLTemplateElement): Node {
21
+ const root = template.content.firstElementChild
22
+ if (!root) throw new Error('Vobs: 静态模板缺少根元素,请检查编译产物')
23
+ return root.cloneNode(true)
24
+ }