@vobs/runtime 1.4.0 → 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 +2 -2
- package/src/dialog-teardown.test.ts +186 -0
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.4.
|
|
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.4.
|
|
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
|
+
})
|