@vobs/runtime 1.7.2 → 1.7.3
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/dist/async-boundary.cjs.map +1 -1
- package/dist/async-boundary.js.map +1 -1
- package/dist/boundary.cjs.map +1 -1
- package/dist/boundary.js.map +1 -1
- package/dist/dynamic.cjs +42 -1
- package/dist/dynamic.cjs.map +1 -1
- package/dist/dynamic.d.cts +4 -0
- package/dist/dynamic.d.ts +4 -0
- package/dist/dynamic.js +42 -1
- package/dist/dynamic.js.map +1 -1
- package/dist/error-boundary.cjs.map +1 -1
- package/dist/error-boundary.js.map +1 -1
- package/dist/index.cjs +29 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +29 -1
- package/dist/index.js.map +1 -1
- package/dist/profiler.cjs.map +1 -1
- package/dist/profiler.js.map +1 -1
- package/package.json +2 -2
- package/src/dynamic.test.ts +118 -2
- package/src/dynamic.ts +38 -2
package/src/dynamic.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import { describe, expect, it } from 'vitest'
|
|
3
|
-
import { createDOMRenderer, setRenderer, createText, insertDynamicValue, insertList, insertDynamic, addEventListener, createComponent } from '@vobs/vobs'
|
|
3
|
+
import { createDOMRenderer, setRenderer, createText, insertDynamicValue, insertList, insertDynamic, addEventListener, createComponent, type DynamicChild } from '@vobs/vobs'
|
|
4
4
|
import { effect, state } from '@vobs/reactivity'
|
|
5
5
|
import { bindText } from './bind'
|
|
6
6
|
import { createFragment, type VobsNode } from './fragment'
|
|
@@ -460,4 +460,120 @@ describe('component render isolation (untrack)', () => {
|
|
|
460
460
|
input.dispatchEvent(new Event('input'))
|
|
461
461
|
expect(content.value).toBe('world')
|
|
462
462
|
})
|
|
463
|
-
})
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
describe('polymorphic insertDynamicValue (Solid 式运行时多态)', () => {
|
|
466
|
+
const flush = () => new Promise(resolve => setTimeout(resolve, 0))
|
|
467
|
+
|
|
468
|
+
it('字符串更新原地改写文本节点(节点身份保持,bindText 级性能)', async () => {
|
|
469
|
+
setRenderer(createDOMRenderer())
|
|
470
|
+
const parent = document.createElement('div')
|
|
471
|
+
const label = state('hello')
|
|
472
|
+
insertDynamicValue(parent, null, () => label.value)
|
|
473
|
+
const textNode = parent.firstChild
|
|
474
|
+
expect(textNode?.nodeType).toBe(3)
|
|
475
|
+
expect(parent.textContent).toBe('hello')
|
|
476
|
+
|
|
477
|
+
label.value = 'world'
|
|
478
|
+
await flush()
|
|
479
|
+
// 快路径:同一个文本节点被复用,只改内容
|
|
480
|
+
expect(parent.firstChild).toBe(textNode)
|
|
481
|
+
expect(parent.textContent).toBe('world')
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
it('数值子节点同样命中文本快路径', async () => {
|
|
485
|
+
setRenderer(createDOMRenderer())
|
|
486
|
+
const parent = document.createElement('div')
|
|
487
|
+
const count = state(0)
|
|
488
|
+
insertDynamicValue(parent, null, () => count.value)
|
|
489
|
+
const textNode = parent.firstChild
|
|
490
|
+
expect(parent.textContent).toBe('0')
|
|
491
|
+
|
|
492
|
+
count.value = 42
|
|
493
|
+
await flush()
|
|
494
|
+
expect(parent.firstChild).toBe(textNode)
|
|
495
|
+
expect(parent.textContent).toBe('42')
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
// 回归(Labelune 踩坑备忘):JSX 子节点里的函数调用返回 Fragment 曾被
|
|
499
|
+
// String() 成 "[object Object]";多态插入后节点值正确挂载,且可与原始值互切。
|
|
500
|
+
it('函数调用返回 Fragment 正确挂载,并与字符串互切', async () => {
|
|
501
|
+
setRenderer(createDOMRenderer())
|
|
502
|
+
const parent = document.createElement('div')
|
|
503
|
+
const mode = state<'doc' | 'plain'>('doc')
|
|
504
|
+
const renderDoc = (): VobsNode => {
|
|
505
|
+
const section = document.createElement('p')
|
|
506
|
+
section.textContent = '条款正文'
|
|
507
|
+
return createFragment((fragmentParent, anchor) => {
|
|
508
|
+
fragmentParent.insertBefore(section, anchor)
|
|
509
|
+
})
|
|
510
|
+
}
|
|
511
|
+
insertDynamicValue(parent, null, () => (mode.value === 'doc' ? renderDoc() : '纯文本'))
|
|
512
|
+
expect(parent.textContent).toBe('条款正文')
|
|
513
|
+
|
|
514
|
+
mode.value = 'plain'
|
|
515
|
+
await flush()
|
|
516
|
+
expect(parent.textContent).toBe('纯文本')
|
|
517
|
+
|
|
518
|
+
mode.value = 'doc'
|
|
519
|
+
await flush()
|
|
520
|
+
expect(parent.textContent).toBe('条款正文')
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
it('节点值变化时整体替换,旧子树 effect 被清理', async () => {
|
|
524
|
+
setRenderer(createDOMRenderer())
|
|
525
|
+
const log: string[] = []
|
|
526
|
+
const version = state(0)
|
|
527
|
+
function Panel(): Node {
|
|
528
|
+
effect(() => {
|
|
529
|
+
log.push(`run:v${version.value}`)
|
|
530
|
+
return () => log.push('cleanup')
|
|
531
|
+
})
|
|
532
|
+
const el = document.createElement('p')
|
|
533
|
+
el.textContent = `v${version.value}`
|
|
534
|
+
return el
|
|
535
|
+
}
|
|
536
|
+
const parent = document.createElement('div')
|
|
537
|
+
// 工厂读取 version 建立结构依赖:version 变化 → 工厂重跑 → 新组件替换旧子树
|
|
538
|
+
// (组件体 untrack,自身渲染不建立依赖——与编译产物的 insertDynamicValue 语义一致)
|
|
539
|
+
insertDynamicValue(parent, null, () => {
|
|
540
|
+
void version.value
|
|
541
|
+
return createComponent(Panel, {})
|
|
542
|
+
})
|
|
543
|
+
expect(parent.textContent).toBe('v0')
|
|
544
|
+
|
|
545
|
+
version.value = 1
|
|
546
|
+
await flush()
|
|
547
|
+
expect(parent.textContent).toBe('v1')
|
|
548
|
+
expect(log).toContain('cleanup')
|
|
549
|
+
expect(log).toContain('run:v1')
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
it('null/undefined/boolean 清空文本与节点子树', async () => {
|
|
553
|
+
setRenderer(createDOMRenderer())
|
|
554
|
+
const parent = document.createElement('div')
|
|
555
|
+
const value = state<DynamicChild>('text')
|
|
556
|
+
insertDynamicValue(parent, null, () => value.value)
|
|
557
|
+
expect(parent.textContent).toBe('text')
|
|
558
|
+
const textNode = parent.firstChild
|
|
559
|
+
|
|
560
|
+
value.value = null
|
|
561
|
+
await flush()
|
|
562
|
+
expect(parent.textContent).toBe('')
|
|
563
|
+
|
|
564
|
+
value.value = false
|
|
565
|
+
await flush()
|
|
566
|
+
expect(parent.textContent).toBe('')
|
|
567
|
+
|
|
568
|
+
const el = document.createElement('span')
|
|
569
|
+
value.value = el
|
|
570
|
+
await flush()
|
|
571
|
+
expect(parent.contains(el)).toBe(true)
|
|
572
|
+
|
|
573
|
+
value.value = false
|
|
574
|
+
await flush()
|
|
575
|
+
expect(parent.contains(el)).toBe(false)
|
|
576
|
+
// 文本节点身份在字符串轮次间保持(此前的 textNode 已随 null 清除)
|
|
577
|
+
expect(parent.contains(textNode as Node)).toBe(false)
|
|
578
|
+
})
|
|
579
|
+
})
|
package/src/dynamic.ts
CHANGED
|
@@ -5,7 +5,8 @@ import {
|
|
|
5
5
|
createComment,
|
|
6
6
|
createText,
|
|
7
7
|
insertBefore,
|
|
8
|
-
removeChild
|
|
8
|
+
removeChild,
|
|
9
|
+
setTextContent
|
|
9
10
|
} from './ops'
|
|
10
11
|
import { createFragment, isVobsFragment, type VobsNode } from './fragment'
|
|
11
12
|
|
|
@@ -41,6 +42,10 @@ export function insertDynamic(parent: Node, anchor: Node | null, factory: NodeFa
|
|
|
41
42
|
/**
|
|
42
43
|
* Inserts any Vobs child value. This is the escape hatch for JSX expressions
|
|
43
44
|
* that return a node, an array of nodes, text, or an empty conditional value.
|
|
45
|
+
*
|
|
46
|
+
* 多态插入(Solid 式):编译器把所有非静态子表达式统一路由到这里,值类型在运行时
|
|
47
|
+
* 分发——原始值走自管理文本节点的原地更新快路径(等价 bindText 性能),节点/Fragment/
|
|
48
|
+
* 数组走 scope 隔离的挂载/卸载路径。函数调用返回节点因此与返回字符串同样安全。
|
|
44
49
|
*/
|
|
45
50
|
export function insertDynamicValue(
|
|
46
51
|
parent: Node,
|
|
@@ -50,13 +55,43 @@ export function insertDynamicValue(
|
|
|
50
55
|
const marker = createComment('vobs:value')
|
|
51
56
|
insertBefore(parent, marker, anchor)
|
|
52
57
|
let current: VobsNode | null = null
|
|
58
|
+
/** current 是否为本 effect 自建的文本节点(可安全原地改写内容) */
|
|
59
|
+
let currentIsText = false
|
|
53
60
|
let scope: Owner | null = null
|
|
54
61
|
|
|
55
62
|
effect(() => {
|
|
56
63
|
// 每轮求值使用独立 scope Owner:factory 求值与挂载期间创建的组件 Owner 全部挂在它下面。
|
|
57
64
|
// 数组/节点被替换时整体 dispose 旧 scope,避免被丢弃子树的 effect 继续订阅信号(幽灵更新与内存泄漏)。
|
|
58
65
|
const nextScope = createOwner()
|
|
59
|
-
const
|
|
66
|
+
const value = nextScope.run(factory)
|
|
67
|
+
// ── 原始值快路径:自建文本节点原地改写,不销毁重建(高频文本零 GC)──
|
|
68
|
+
if (typeof value === 'string' || typeof value === 'number') {
|
|
69
|
+
if (currentIsText) {
|
|
70
|
+
setTextContent(current as Text, String(value))
|
|
71
|
+
nextScope.dispose()
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
if (current) removeChild(parent, current)
|
|
75
|
+
scope?.dispose()
|
|
76
|
+
scope = null
|
|
77
|
+
const text = createText(String(value))
|
|
78
|
+
insertBefore(parent, text, marker)
|
|
79
|
+
current = text
|
|
80
|
+
currentIsText = true
|
|
81
|
+
nextScope.dispose()
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
if (value === null || value === undefined || typeof value === 'boolean') {
|
|
85
|
+
if (current) removeChild(parent, current)
|
|
86
|
+
scope?.dispose()
|
|
87
|
+
scope = null
|
|
88
|
+
current = null
|
|
89
|
+
currentIsText = false
|
|
90
|
+
nextScope.dispose()
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
// ── 节点/Fragment/数组路径:scope 隔离的挂载与整体替换 ──
|
|
94
|
+
const next = nextScope.run(() => normalizeDynamicChild(value))
|
|
60
95
|
if (next === current) {
|
|
61
96
|
nextScope.dispose()
|
|
62
97
|
return
|
|
@@ -65,6 +100,7 @@ export function insertDynamicValue(
|
|
|
65
100
|
scope?.dispose()
|
|
66
101
|
scope = nextScope
|
|
67
102
|
current = next
|
|
103
|
+
currentIsText = false
|
|
68
104
|
if (current) insertBefore(parent, current, marker)
|
|
69
105
|
})
|
|
70
106
|
}
|