@vobs/runtime 1.3.7 → 1.4.1

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
@@ -11,7 +11,7 @@
11
11
  "type": "git",
12
12
  "url": "git+https://github.com/vobsjs/vobs.git"
13
13
  },
14
- "version": "1.3.7",
14
+ "version": "1.4.1",
15
15
  "publishConfig": {
16
16
  "access": "public"
17
17
  },
@@ -39,6 +39,6 @@
39
39
  "./source/*": "./src/*"
40
40
  },
41
41
  "dependencies": {
42
- "@vobs/reactivity": "1.3.7"
42
+ "@vobs/reactivity": "1.4.1"
43
43
  }
44
44
  }
@@ -0,0 +1,186 @@
1
+ // @vitest-environment jsdom
2
+ // 回归测试(源自 Labelune 弹窗踩坑,docs/todo.md 备忘):
3
+ // ① Dialog 条件 children → null 时与兄弟内容共存(应用层「开关与数据分离」模式的语义保证)
4
+ // ② 用户态组件透传 children 按节点渲染(编译产物形态模拟,编译器侧另有产物断言)
5
+ // ③ insertDynamic 卸载子树时旧 effect 一并清理(幽灵订阅检测)
6
+ import { describe, expect, it } from 'vitest'
7
+ import { createDOMRenderer, setRenderer, createComponent, insertBefore, insertDynamic, insertDynamicValue } from '@vobs/vobs'
8
+ import { effect, state } from '@vobs/reactivity'
9
+
10
+ const flush = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0))
11
+
12
+ describe('Dialog 条件 children 与兄弟内容共存', () => {
13
+ // 模拟 Labelune TemplatesPage 结构:页面先有内容,末尾挂 Dialog(children 条件渲染)
14
+ it('关闭弹窗(open 与 children 同一信号)后兄弟内容仍在', async () => {
15
+ setRenderer(createDOMRenderer())
16
+ const request = state<{ message: string } | null>(null)
17
+
18
+ // 模拟编译产物:children getter + open getter 都读同一个信号
19
+ function buildBody(): HTMLElement {
20
+ const div = document.createElement('div')
21
+ div.className = 'template-dialog'
22
+ div.textContent = 'dialog-body'
23
+ return div
24
+ }
25
+
26
+ // @vobs/ui Dialog 的核心结构:backdrop(root) > surface > (head) + body(mountSlot → insertDynamic)
27
+ const root = document.createElement('div')
28
+ root.setAttribute('role', 'presentation')
29
+
30
+ const surface = document.createElement('section')
31
+ surface.setAttribute('role', 'dialog')
32
+ const body = document.createElement('div')
33
+ surface.appendChild(body)
34
+ root.appendChild(surface)
35
+
36
+ // Dialog 的 open effect(模拟 dialog.ts 第 70-79 行)
37
+ effect(() => {
38
+ const open = request.value !== null
39
+ ;(root as HTMLElement & { hidden: boolean }).hidden = !open
40
+ })
41
+
42
+ // mountSlot:children 插槽
43
+ insertDynamic(body, null, () => {
44
+ const value = request.value !== null ? buildBody() : null
45
+ return value
46
+ })
47
+
48
+ // 页面结构:panel 在 Dialog 之前(同级兄弟)
49
+ const page = document.createElement('div')
50
+ const panel = document.createElement('div')
51
+ panel.className = 'templates-grid'
52
+ panel.textContent = 'template-card-content'
53
+ page.appendChild(panel)
54
+ page.appendChild(root)
55
+
56
+ // 打开弹窗:open 与 children 同时为真
57
+ request.set({ message: '删除模板?' })
58
+ await flush()
59
+ expect(body.querySelector('.template-dialog')).not.toBeNull()
60
+ expect(page.querySelector('.templates-grid')).not.toBeNull()
61
+
62
+ // 关闭弹窗:open 与 children 同时变 null(应用层最初的写法)
63
+ request.set(null)
64
+ await flush()
65
+ expect(body.querySelector('.template-dialog')).toBeNull()
66
+ // 关键断言:兄弟内容不消失
67
+ expect(page.querySelector('.templates-grid')).not.toBeNull()
68
+ expect(page.textContent).toContain('template-card-content')
69
+ })
70
+ })
71
+
72
+ describe('用户态组件透传 children', () => {
73
+ // 模拟编译产物:function Section({children}) { return <section>{children}</section> }
74
+ // children 编译为 props 上的 getter(get accessor),读取时求值
75
+ it('children 为节点时正常渲染而非 [object HTMLDivElement]', async () => {
76
+ setRenderer(createDOMRenderer())
77
+ function Section(props: { readonly children?: unknown }): Node {
78
+ const section = document.createElement('section')
79
+ // children 运行时为节点,类型上 unknown 需收敛到 DynamicChild 可接受形态
80
+ insertDynamicValue(section, null, () => Reflect.get(props, 'children') as never)
81
+ return section
82
+ }
83
+ // 调用方编译产物:children getter 返回构建好的节点(transformElement IIFE 求值结果)
84
+ const node = createComponent(Section, {
85
+ get children() {
86
+ const div = document.createElement('div')
87
+ div.textContent = 'inner'
88
+ return div
89
+ }
90
+ })
91
+ const host = document.createElement('div')
92
+ insertBefore(host, node, null)
93
+ expect(host.textContent).toBe('inner')
94
+ expect(host.textContent).not.toContain('[object')
95
+ })
96
+
97
+ it('children 为节点数组时展开渲染', () => {
98
+ setRenderer(createDOMRenderer())
99
+ function Section(props: { readonly children?: unknown }): Node {
100
+ const section = document.createElement('section')
101
+ // children 运行时为节点/节点数组,类型上 unknown 需收敛到 DynamicChild 可接受形态
102
+ insertDynamicValue(section, null, () => Reflect.get(props, 'children') as never)
103
+ return section
104
+ }
105
+ const node = createComponent(Section, {
106
+ get children() {
107
+ const a = document.createElement('span')
108
+ a.textContent = 'a'
109
+ const b = document.createElement('span')
110
+ b.textContent = 'b'
111
+ return [a, b]
112
+ }
113
+ })
114
+ const host = document.createElement('div')
115
+ insertBefore(host, node, null)
116
+ expect(host.textContent).toBe('ab')
117
+ })
118
+ })
119
+
120
+ describe('insertDynamic 卸载子树的 effect 清理', () => {
121
+ it('条件翻转为 null 后旧组件 effect 被清理(幽灵订阅检测)', async () => {
122
+ setRenderer(createDOMRenderer())
123
+ const visible = state(true)
124
+ const unrelated = state(0)
125
+ let cleanups = 0
126
+ function Inner(): Node {
127
+ effect(() => {
128
+ unrelated.value // 组件体读取信号
129
+ return () => {
130
+ cleanups += 1
131
+ }
132
+ })
133
+ return document.createElement('span')
134
+ }
135
+ const parent = document.createElement('div')
136
+ insertDynamic(parent, null, () => (visible.value ? createComponent(Inner, {}) : null))
137
+ await flush()
138
+ expect(parent.querySelector('span')).not.toBeNull()
139
+
140
+ visible.set(false)
141
+ await flush()
142
+ expect(parent.querySelector('span')).toBeNull()
143
+ // 关键断言:旧 effect 已清理,不再订阅 unrelated
144
+ expect(cleanups).toBe(1)
145
+
146
+ unrelated.set(99)
147
+ await flush()
148
+ expect(cleanups).toBe(1)
149
+ })
150
+
151
+ it('嵌套组件 owner 随子树卸载一并清理', async () => {
152
+ setRenderer(createDOMRenderer())
153
+ const visible = state(true)
154
+ const unrelated = state(0)
155
+ const cleanups: string[] = []
156
+ function Outer(): Node {
157
+ effect(() => {
158
+ unrelated.value
159
+ return () => cleanups.push('outer')
160
+ })
161
+ const wrap = document.createElement('div')
162
+ insertBefore(wrap, createComponent(Inner, {}), null)
163
+ return wrap
164
+ }
165
+ function Inner(): Node {
166
+ effect(() => {
167
+ unrelated.value
168
+ return () => cleanups.push('inner')
169
+ })
170
+ return document.createElement('span')
171
+ }
172
+ const parent = document.createElement('div')
173
+ insertDynamic(parent, null, () => (visible.value ? createComponent(Outer, {}) : null))
174
+ await flush()
175
+
176
+ visible.set(false)
177
+ await flush()
178
+ expect(cleanups).toContain('outer')
179
+ expect(cleanups).toContain('inner')
180
+
181
+ unrelated.set(1)
182
+ await flush()
183
+ expect(cleanups.filter(entry => entry === 'outer')).toHaveLength(1)
184
+ expect(cleanups.filter(entry => entry === 'inner')).toHaveLength(1)
185
+ })
186
+ })
@@ -1,6 +1,8 @@
1
1
  // @vitest-environment jsdom
2
2
  import { describe, expect, it } from 'vitest'
3
3
  import { createDOMRenderer, setRenderer } from '@vobs/vobs'
4
+ import type { Signal } from '@vobs/vobs'
5
+ import { effect, createOwner, state } from '@vobs/reactivity'
4
6
  import { addEventListener, createElement, removeEventListener } from './index'
5
7
 
6
8
  describe('event binding dedupe', () => {
@@ -18,4 +20,40 @@ describe('event binding dedupe', () => {
18
20
  node.dispatchEvent(new MouseEvent('click'))
19
21
  expect(second).toBe(1)
20
22
  })
23
+
24
+ it('signals created inside event handlers survive owner disposal', async () => {
25
+ // 语义锁定:addEventListener 把编译事件处理器包进 owner.run 执行(错误路由 +
26
+ // 游离 DOM 防护)。处理器里创建的 state 是应用数据,作用域销毁后必须仍可写。
27
+ // 回归背景:下拉菜单关闭销毁作用域,曾把菜单点击创建的元素信号连带销毁,
28
+ // 导致"元素无法移动/缩放"(set 静默无效)。
29
+ setRenderer(createDOMRenderer())
30
+ const owner = createOwner()
31
+ const node = createElement('button')
32
+
33
+ let created: Signal<number> | null = null
34
+ owner.run(() => {
35
+ addEventListener(node, 'click', () => {
36
+ if (created === null) created = state(0, 'handler.created')
37
+ created.set(created.value + 1)
38
+ })
39
+ })
40
+
41
+ node.dispatchEvent(new MouseEvent('click'))
42
+ node.dispatchEvent(new MouseEvent('click'))
43
+ expect(created).not.toBeNull()
44
+ expect(created!.value).toBe(2)
45
+
46
+ // 作用域销毁(如菜单关闭):游离 DOM 防护继续生效——事件不再进入处理器
47
+ owner.dispose()
48
+ node.dispatchEvent(new MouseEvent('click'))
49
+ expect(created!.value).toBe(2)
50
+
51
+ // 但信号本身存活:仍可写、仍可被新效果订阅(数据寿命 = 可达性)
52
+ const seen: number[] = []
53
+ effect(() => { seen.push(created!.value) })
54
+ created!.set(7)
55
+ await Promise.resolve()
56
+ expect(created!.value).toBe(7)
57
+ expect(seen).toEqual([2, 7])
58
+ })
21
59
  })