@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.
@@ -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 next = nextScope.run(() => normalizeDynamicChild(factory()))
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
  }