@vobs/runtime 1.7.2 → 1.7.4

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
  }
package/src/ops.ts CHANGED
@@ -44,7 +44,35 @@ export function createText(content: string): Text {
44
44
  return getRenderer().createText(content)
45
45
  }
46
46
 
47
+ /** SVG 命名空间(与 HTML 解析器 foreign-content 规则一致) */
48
+ export const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
49
+
50
+ /**
51
+ * SVG 标签全集(= TypeScript SVGElementTagNameMap 的键,运行时分发依据)。
52
+ * 与 React 的运行时清单同思路:只含纯 SVG 标签,刻意排除与 HTML 同名的
53
+ * a/script/style/title(它们在 JSX 类型里归 HTMLElement,按 HTML 创建)。
54
+ */
55
+ const SVG_TAGS = new Set([
56
+ 'animate', 'animateMotion', 'animateTransform', 'circle', 'clipPath', 'defs', 'desc',
57
+ 'ellipse', 'feBlend', 'feColorMatrix', 'feComponentTransfer', 'feComposite',
58
+ 'feConvolveMatrix', 'feDiffuseLighting', 'feDisplacementMap', 'feDistantLight',
59
+ 'feDropShadow', 'feFlood', 'feFuncA', 'feFuncB', 'feFuncG', 'feFuncR',
60
+ 'feGaussianBlur', 'feImage', 'feMerge', 'feMergeNode', 'feMorphology', 'feOffset',
61
+ 'fePointLight', 'feSpecularLighting', 'feSpotLight', 'feTile', 'feTurbulence',
62
+ 'filter', 'foreignObject', 'g', 'image', 'line', 'linearGradient', 'marker', 'mask',
63
+ 'metadata', 'mpath', 'path', 'pattern', 'polygon', 'polyline', 'radialGradient',
64
+ 'rect', 'set', 'stop', 'svg', 'switch', 'symbol', 'text', 'textPath', 'tspan',
65
+ 'use', 'view'
66
+ ])
67
+
47
68
  export function createElement(tag: string): Element {
69
+ // SVG 标签走命名空间创建:document.createElement('rect') 产物是 HTMLUnknownElement,
70
+ // 整棵 SVG 子树都不会渲染(无报错)。渲染器未实现 createSvgElement 时按 createElement
71
+ // 兜底(旧自定义渲染器保持原行为)。
72
+ if (SVG_TAGS.has(tag)) {
73
+ const renderer = getRenderer()
74
+ if (renderer.createSvgElement) return renderer.createSvgElement(tag)
75
+ }
48
76
  return getRenderer().createElement(tag)
49
77
  }
50
78
 
package/src/renderer.ts CHANGED
@@ -6,6 +6,11 @@ export interface VobsRenderer<
6
6
  > {
7
7
  createText(content: string): TextNode
8
8
  createElement(tag: string): ElementNode
9
+ /**
10
+ * 创建 SVG 命名空间元素(http://www.w3.org/2000/svg)。可选:未实现时运行时按
11
+ * createElement 兜底(HTML namespace,SVG 内容不渲染)。DOM/SSR/水合渲染器均需实现。
12
+ */
13
+ createSvgElement?(tag: string): ElementNode
9
14
  createComment(content: string): CommentNode
10
15
  insertBefore(parent: NodeType, child: NodeType, anchor: NodeType | null): void
11
16
  removeChild(parent: NodeType, child: NodeType): void
@@ -0,0 +1,59 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from 'vitest'
3
+ import { createDOMRenderer, setRenderer, createElement, insertBefore } from '@vobs/vobs'
4
+
5
+ // 回归(Labelune 踩坑备忘):createElement('svg'/'rect') 此前走 HTML namespace,
6
+ // 产物是 HTMLUnknownElement——整棵 SVG 子树静默不渲染且无报错。
7
+ // 1.7.4 起 createElement 按 SVG 标签全集分发到渲染器 createSvgElement(createElementNS)。
8
+ describe('svg namespace element creation', () => {
9
+ it('creates SVG-namespace elements for SVG tags and HTML for the rest', () => {
10
+ setRenderer(createDOMRenderer())
11
+ const svg = createElement('svg')
12
+ expect(svg).toBeInstanceOf(SVGElement)
13
+ expect(svg.namespaceURI).toBe('http://www.w3.org/2000/svg')
14
+
15
+ const rect = createElement('rect')
16
+ expect(rect).toBeInstanceOf(SVGElement)
17
+ expect(rect.namespaceURI).toBe('http://www.w3.org/2000/svg')
18
+
19
+ // 驼峰 SVG 标签(clipPath/linearGradient)同样命中
20
+ expect(createElement('clipPath').namespaceURI).toBe('http://www.w3.org/2000/svg')
21
+
22
+ // 与 HTML 同名集合之外的普通标签仍是 HTML namespace
23
+ const div = createElement('div')
24
+ expect(div).toBeInstanceOf(HTMLDivElement)
25
+ expect(div.namespaceURI).toBe('http://www.w3.org/1999/xhtml')
26
+ })
27
+
28
+ it('builds a renderable, queryable svg tree via createElement/insertBefore', () => {
29
+ setRenderer(createDOMRenderer())
30
+ const svg = createElement('svg')
31
+ svg.setAttribute('viewBox', '0 0 50 30')
32
+ const rect = createElement('rect')
33
+ rect.setAttribute('x', '0')
34
+ rect.setAttribute('y', '0')
35
+ rect.setAttribute('width', '50')
36
+ rect.setAttribute('height', '30')
37
+ insertBefore(svg, rect, null)
38
+
39
+ const host = document.createElement('div')
40
+ insertBefore(host, svg, null)
41
+
42
+ // 真实 SVG 子树:querySelector 命中、命名空间正确、属性大小写保留
43
+ expect(host.querySelector('svg')).not.toBeNull()
44
+ const found = host.querySelector('rect')
45
+ expect(found).not.toBeNull()
46
+ expect(found!.namespaceURI).toBe('http://www.w3.org/2000/svg')
47
+ expect(svg.getAttribute('viewBox')).toBe('0 0 50 30')
48
+ })
49
+
50
+ it('falls back to createElement when the renderer lacks createSvgElement', () => {
51
+ setRenderer({
52
+ ...createDOMRenderer(),
53
+ createSvgElement: undefined
54
+ })
55
+ const rect = createElement('rect')
56
+ // 旧自定义渲染器兜底:按 HTML 创建(保持原行为,不抛错)
57
+ expect(rect.namespaceURI).not.toBe('http://www.w3.org/2000/svg')
58
+ })
59
+ })