@vobs/runtime 1.0.0
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/LICENSE +21 -0
- package/README.md +79 -0
- package/package.json +20 -0
- package/src/async-boundary.ts +97 -0
- package/src/bind.ts +47 -0
- package/src/boundaries.test.ts +59 -0
- package/src/boundary.ts +93 -0
- package/src/debug.ts +146 -0
- package/src/dynamic.test.ts +228 -0
- package/src/dynamic.ts +266 -0
- package/src/error-boundary.ts +35 -0
- package/src/error.test.ts +28 -0
- package/src/error.ts +175 -0
- package/src/events.test.ts +21 -0
- package/src/fragment.ts +81 -0
- package/src/hmr.test.ts +55 -0
- package/src/hmr.ts +123 -0
- package/src/index.ts +81 -0
- package/src/ops.ts +377 -0
- package/src/profiler.ts +45 -0
- package/src/props.test.ts +54 -0
- package/src/ref.test.ts +23 -0
- package/src/ref.ts +50 -0
- package/src/renderer.ts +21 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { createDOMRenderer, setRenderer, createText, insertDynamicValue, insertList, createComponent } from '@vobs/vobs'
|
|
4
|
+
import { effect, state } from '@vobs/reactivity'
|
|
5
|
+
import { bindText } from './bind'
|
|
6
|
+
import { createFragment, type VobsNode } from './fragment'
|
|
7
|
+
|
|
8
|
+
describe('dynamic JSX child normalization', () => {
|
|
9
|
+
it('ignores empty conditional values and expands arrays without a wrapper', () => {
|
|
10
|
+
setRenderer(createDOMRenderer())
|
|
11
|
+
const parent = document.createElement('div')
|
|
12
|
+
insertDynamicValue(parent, null, () => [createText('a'), false, null, createText('b')])
|
|
13
|
+
expect(parent.textContent).toBe('ab')
|
|
14
|
+
expect(parent.querySelector('vobs-value')).toBeNull()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('formats primitive children and nested arrays without stringifying booleans', () => {
|
|
18
|
+
setRenderer(createDOMRenderer())
|
|
19
|
+
const parent = document.createElement('div')
|
|
20
|
+
insertDynamicValue(parent, null, () => ['hello', false, [' ', 2]])
|
|
21
|
+
expect(parent.textContent).toBe('hello 2')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('renders null, undefined and booleans as empty text', () => {
|
|
25
|
+
setRenderer(createDOMRenderer())
|
|
26
|
+
const node = document.createTextNode('')
|
|
27
|
+
const parent = document.createElement('div')
|
|
28
|
+
parent.append(node)
|
|
29
|
+
bindText(node, () => false)
|
|
30
|
+
expect(parent.textContent).toBe('')
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('array dynamic child disposal', () => {
|
|
35
|
+
it('disposes inner component owners when the array is swapped', async () => {
|
|
36
|
+
setRenderer(createDOMRenderer())
|
|
37
|
+
const log: string[] = []
|
|
38
|
+
let mountCount = 0
|
|
39
|
+
function Inner(): Node {
|
|
40
|
+
// 按实例打标:数组替换后新组件的 effect 也会合法运行,不能共享计数
|
|
41
|
+
const instance = ++mountCount
|
|
42
|
+
effect(() => {
|
|
43
|
+
log.push(`run:${instance}`)
|
|
44
|
+
return () => log.push(`cleanup:${instance}`)
|
|
45
|
+
})
|
|
46
|
+
return document.createElement('span')
|
|
47
|
+
}
|
|
48
|
+
const parent = document.createElement('div')
|
|
49
|
+
const items = state([1])
|
|
50
|
+
insertDynamicValue(parent, null, () => items.value.map(() => createComponent(Inner, {})))
|
|
51
|
+
expect(log).toEqual(['run:1'])
|
|
52
|
+
|
|
53
|
+
items.value = [2]
|
|
54
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
55
|
+
// 旧组件的 effect 必须被清理,而不是继续订阅信号写已脱离的 DOM
|
|
56
|
+
expect(log).toContain('cleanup:1')
|
|
57
|
+
expect(log).toContain('run:2')
|
|
58
|
+
expect(log).not.toContain('cleanup:2')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('disposed inner effects stop reacting to their signals', async () => {
|
|
62
|
+
setRenderer(createDOMRenderer())
|
|
63
|
+
const counter = state(0)
|
|
64
|
+
interface Instance {
|
|
65
|
+
runs: number
|
|
66
|
+
disposed: boolean
|
|
67
|
+
}
|
|
68
|
+
const mounted: Instance[] = []
|
|
69
|
+
function Inner(): Node {
|
|
70
|
+
const instance: Instance = { runs: 0, disposed: false }
|
|
71
|
+
mounted.push(instance)
|
|
72
|
+
effect(() => {
|
|
73
|
+
counter.value
|
|
74
|
+
instance.runs++
|
|
75
|
+
return () => { instance.disposed = true }
|
|
76
|
+
})
|
|
77
|
+
return document.createElement('span')
|
|
78
|
+
}
|
|
79
|
+
const parent = document.createElement('div')
|
|
80
|
+
const items = state([1])
|
|
81
|
+
insertDynamicValue(parent, null, () => items.value.map(() => createComponent(Inner, {})))
|
|
82
|
+
const first = mounted[0]
|
|
83
|
+
expect(first.runs).toBe(1)
|
|
84
|
+
|
|
85
|
+
items.value = [2]
|
|
86
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
87
|
+
expect(first.disposed).toBe(true)
|
|
88
|
+
|
|
89
|
+
counter.value = 1
|
|
90
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
91
|
+
// 幽灵 effect 不允许再被触发
|
|
92
|
+
expect(first.runs).toBe(1)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('disposes multi-root components nested in swapped arrays', async () => {
|
|
96
|
+
setRenderer(createDOMRenderer())
|
|
97
|
+
const log: string[] = []
|
|
98
|
+
function MultiRoot(): VobsNode {
|
|
99
|
+
effect(() => {
|
|
100
|
+
log.push('run')
|
|
101
|
+
return () => log.push('cleanup')
|
|
102
|
+
})
|
|
103
|
+
const first = document.createElement('span')
|
|
104
|
+
const second = document.createElement('span')
|
|
105
|
+
return createFragment((parent, anchor) => {
|
|
106
|
+
parent.insertBefore(first, anchor)
|
|
107
|
+
parent.insertBefore(second, anchor)
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
const parent = document.createElement('div')
|
|
111
|
+
const items = state([1])
|
|
112
|
+
insertDynamicValue(parent, null, () => items.value.map(() => createComponent(MultiRoot, {})))
|
|
113
|
+
items.value = [2]
|
|
114
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
115
|
+
expect(log).toContain('cleanup')
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('primitive list item updates', () => {
|
|
120
|
+
// 模拟编译产物:{item} 编译为 insertDynamicValue(() => item),
|
|
121
|
+
// 原始值被静态捕获,不订阅 item 信号。
|
|
122
|
+
function compiledRow(item: string): Node {
|
|
123
|
+
const span = document.createElement('span')
|
|
124
|
+
const text = document.createTextNode('')
|
|
125
|
+
span.append(text)
|
|
126
|
+
insertDynamicValue(span, null, () => item)
|
|
127
|
+
return span
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function loggedRow(item: string, log: string[]): Node {
|
|
131
|
+
const span = document.createElement('span')
|
|
132
|
+
const text = document.createTextNode('')
|
|
133
|
+
span.append(text)
|
|
134
|
+
effect(() => {
|
|
135
|
+
log.push(`run:${item}`)
|
|
136
|
+
return () => log.push(`cleanup:${item}`)
|
|
137
|
+
})
|
|
138
|
+
insertDynamicValue(span, null, () => item)
|
|
139
|
+
return span
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
it('updates indexed rows when primitive items change', async () => {
|
|
143
|
+
setRenderer(createDOMRenderer())
|
|
144
|
+
const parent = document.createElement('div')
|
|
145
|
+
const items = state(['a', 'b'])
|
|
146
|
+
insertList(parent, null, () => items.value, item => compiledRow(item))
|
|
147
|
+
expect(parent.textContent).toBe('ab')
|
|
148
|
+
|
|
149
|
+
items.value = ['a', 'c']
|
|
150
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
151
|
+
// 原始类型项无法通过 item 信号刷新视图,必须重建该行
|
|
152
|
+
expect(parent.textContent).toBe('ac')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('updates keyed rows when the value changes under a stable key', async () => {
|
|
156
|
+
setRenderer(createDOMRenderer())
|
|
157
|
+
const parent = document.createElement('div')
|
|
158
|
+
const items = state(['a', 'b'])
|
|
159
|
+
insertList(parent, null, () => items.value, item => compiledRow(item), (_item, index) => index)
|
|
160
|
+
expect(parent.textContent).toBe('ab')
|
|
161
|
+
|
|
162
|
+
items.value = ['x', 'y']
|
|
163
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
164
|
+
expect(parent.textContent).toBe('xy')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('keeps rows when primitive values are unchanged', async () => {
|
|
168
|
+
setRenderer(createDOMRenderer())
|
|
169
|
+
const log: string[] = []
|
|
170
|
+
const parent = document.createElement('div')
|
|
171
|
+
const items = state(['a', 'b'])
|
|
172
|
+
insertList(parent, null, () => items.value, item => loggedRow(item, log))
|
|
173
|
+
const runsAfterMount = log.filter(entry => entry.startsWith('run:')).length
|
|
174
|
+
|
|
175
|
+
items.value = ['a', 'b']
|
|
176
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
177
|
+
expect(parent.textContent).toBe('ab')
|
|
178
|
+
expect(log.filter(entry => entry.startsWith('cleanup:'))).toHaveLength(0)
|
|
179
|
+
expect(log.filter(entry => entry.startsWith('run:'))).toHaveLength(runsAfterMount)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('disposes replaced primitive rows and keeps untouched rows', async () => {
|
|
183
|
+
setRenderer(createDOMRenderer())
|
|
184
|
+
const log: string[] = []
|
|
185
|
+
const parent = document.createElement('div')
|
|
186
|
+
const items = state(['a', 'b'])
|
|
187
|
+
insertList(parent, null, () => items.value, item => loggedRow(item, log))
|
|
188
|
+
|
|
189
|
+
items.value = ['c', 'b']
|
|
190
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
191
|
+
expect(parent.textContent).toBe('cb')
|
|
192
|
+
expect(log).toContain('cleanup:a')
|
|
193
|
+
expect(log).toContain('run:c')
|
|
194
|
+
expect(log).not.toContain('cleanup:b')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('still updates object rows in place through reactive proxies', async () => {
|
|
198
|
+
setRenderer(createDOMRenderer())
|
|
199
|
+
const parent = document.createElement('div')
|
|
200
|
+
const users = state([
|
|
201
|
+
{ id: 1, name: 'a' },
|
|
202
|
+
{ id: 2, name: 'b' }
|
|
203
|
+
])
|
|
204
|
+
insertList(
|
|
205
|
+
parent,
|
|
206
|
+
null,
|
|
207
|
+
() => users.value,
|
|
208
|
+
user => {
|
|
209
|
+
const span = document.createElement('span')
|
|
210
|
+
const text = document.createTextNode('')
|
|
211
|
+
span.append(text)
|
|
212
|
+
// 模拟编译产物:{user.name} 经代理转发 item.value,在行内 effect 中被追踪
|
|
213
|
+
insertDynamicValue(span, null, () => user.name)
|
|
214
|
+
return span
|
|
215
|
+
},
|
|
216
|
+
user => user.id
|
|
217
|
+
)
|
|
218
|
+
expect(parent.textContent).toBe('ab')
|
|
219
|
+
|
|
220
|
+
users.value = [
|
|
221
|
+
{ id: 1, name: 'a2' },
|
|
222
|
+
{ id: 2, name: 'b2' }
|
|
223
|
+
]
|
|
224
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
225
|
+
expect(parent.textContent).toBe('a2b2')
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
package/src/dynamic.ts
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { createOwner, effect, state, type Owner, type Signal } from '@vobs/reactivity'
|
|
2
|
+
import {
|
|
3
|
+
associateNodeOwner,
|
|
4
|
+
createBlock,
|
|
5
|
+
createComment,
|
|
6
|
+
createText,
|
|
7
|
+
insertBefore,
|
|
8
|
+
removeChild
|
|
9
|
+
} from './ops'
|
|
10
|
+
import { createFragment, isVobsFragment, type VobsNode } from './fragment'
|
|
11
|
+
|
|
12
|
+
export type NodeFactory = () => VobsNode | null | undefined | false
|
|
13
|
+
export type DynamicChild = VobsNode | string | number | boolean | null | undefined | readonly DynamicChild[]
|
|
14
|
+
|
|
15
|
+
interface ListEntry<T> {
|
|
16
|
+
key: unknown
|
|
17
|
+
node: VobsNode
|
|
18
|
+
owner: Owner
|
|
19
|
+
viewOwner: Owner
|
|
20
|
+
item: Signal<T>
|
|
21
|
+
/** 最近一次赋值的原始 item:供比较与刷新使用,避免在列表 effect 内读取 item 信号造成自依赖。 */
|
|
22
|
+
value: T
|
|
23
|
+
index: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function insertDynamic(parent: Node, anchor: Node | null, factory: NodeFactory): void {
|
|
27
|
+
const marker = createComment('vobs:dynamic')
|
|
28
|
+
insertBefore(parent, marker, anchor)
|
|
29
|
+
let current: VobsNode | null = null
|
|
30
|
+
|
|
31
|
+
effect(() => {
|
|
32
|
+
const next = createBlock(factory)
|
|
33
|
+
if (next === current) return
|
|
34
|
+
|
|
35
|
+
if (current) removeChild(parent, current)
|
|
36
|
+
current = next
|
|
37
|
+
if (current) insertBefore(parent, current, marker)
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Inserts any Vobs child value. This is the escape hatch for JSX expressions
|
|
43
|
+
* that return a node, an array of nodes, text, or an empty conditional value.
|
|
44
|
+
*/
|
|
45
|
+
export function insertDynamicValue(
|
|
46
|
+
parent: Node,
|
|
47
|
+
anchor: Node | null,
|
|
48
|
+
factory: () => DynamicChild
|
|
49
|
+
): void {
|
|
50
|
+
const marker = createComment('vobs:value')
|
|
51
|
+
insertBefore(parent, marker, anchor)
|
|
52
|
+
let current: VobsNode | null = null
|
|
53
|
+
let scope: Owner | null = null
|
|
54
|
+
|
|
55
|
+
effect(() => {
|
|
56
|
+
// 每轮求值使用独立 scope Owner:factory 求值与挂载期间创建的组件 Owner 全部挂在它下面。
|
|
57
|
+
// 数组/节点被替换时整体 dispose 旧 scope,避免被丢弃子树的 effect 继续订阅信号(幽灵更新与内存泄漏)。
|
|
58
|
+
const nextScope = createOwner()
|
|
59
|
+
const next = nextScope.run(() => normalizeDynamicChild(factory()))
|
|
60
|
+
if (next === current) {
|
|
61
|
+
nextScope.dispose()
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
if (current) removeChild(parent, current)
|
|
65
|
+
scope?.dispose()
|
|
66
|
+
scope = nextScope
|
|
67
|
+
current = next
|
|
68
|
+
if (current) insertBefore(parent, current, marker)
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Convert a JSX child value to a host node without creating a wrapper element. */
|
|
73
|
+
export function normalizeDynamicChild(value: DynamicChild): VobsNode | null {
|
|
74
|
+
if (value === null || value === undefined || typeof value === 'boolean') return null
|
|
75
|
+
if (typeof value === 'string' || typeof value === 'number') return createText(String(value))
|
|
76
|
+
if (isVobsFragment(value) || isHostNode(value)) return value as VobsNode
|
|
77
|
+
if (Array.isArray(value)) {
|
|
78
|
+
const children = value
|
|
79
|
+
if (children.length === 0) return null
|
|
80
|
+
return createFragment((parent, anchor) => {
|
|
81
|
+
for (const child of children) {
|
|
82
|
+
const node = normalizeDynamicChild(child)
|
|
83
|
+
if (node) insertBefore(parent, node, anchor)
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isHostNode(value: unknown): value is Node {
|
|
91
|
+
return Boolean(value && typeof value === 'object'
|
|
92
|
+
&& ('nodeType' in value || ((value as { type?: unknown }).type === 'element'
|
|
93
|
+
|| (value as { type?: unknown }).type === 'text'
|
|
94
|
+
|| (value as { type?: unknown }).type === 'comment')))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function insertList<T>(
|
|
98
|
+
parent: Node,
|
|
99
|
+
anchor: Node | null,
|
|
100
|
+
source: () => readonly T[],
|
|
101
|
+
renderItem: (item: T, index: number) => VobsNode,
|
|
102
|
+
keyOf?: (item: T, index: number) => unknown
|
|
103
|
+
): void {
|
|
104
|
+
const marker = createComment('vobs:list')
|
|
105
|
+
insertBefore(parent, marker, anchor)
|
|
106
|
+
let entries: Array<ListEntry<T>> = []
|
|
107
|
+
const tracksIndex = renderItem.length >= 2
|
|
108
|
+
|
|
109
|
+
effect(() => {
|
|
110
|
+
const items = source()
|
|
111
|
+
const keyed = keyOf && items.every((item, index) => keyOf(item, index) != null)
|
|
112
|
+
const nextEntries = keyed
|
|
113
|
+
? reconcileKeyed(items, entries, renderItem, keyOf)
|
|
114
|
+
: reconcileIndexed(items, entries, renderItem)
|
|
115
|
+
|
|
116
|
+
for (let index = 0; index < nextEntries.length; index++) {
|
|
117
|
+
const entry = nextEntries[index]
|
|
118
|
+
if (tracksIndex && entry && entry.index !== index) refreshListEntry(parent, entry, index, renderItem)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
if (!nextEntries.includes(entry)) disposeEntry(parent, entry)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let reference: VobsNode | null = marker
|
|
126
|
+
for (let index = nextEntries.length - 1; index >= 0; index--) {
|
|
127
|
+
insertBefore(parent, nextEntries[index].node, reference)
|
|
128
|
+
reference = nextEntries[index].node
|
|
129
|
+
}
|
|
130
|
+
entries = nextEntries
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function reconcileKeyed<T>(
|
|
135
|
+
items: readonly T[],
|
|
136
|
+
entries: Array<ListEntry<T>>,
|
|
137
|
+
renderItem: (item: T, index: number) => VobsNode,
|
|
138
|
+
keyOf: (item: T, index: number) => unknown
|
|
139
|
+
): Array<ListEntry<T>> {
|
|
140
|
+
const previous = new Map(entries.map(entry => [entry.key, entry]))
|
|
141
|
+
const seen = new Set<unknown>()
|
|
142
|
+
const nextEntries: Array<ListEntry<T>> = []
|
|
143
|
+
|
|
144
|
+
for (let index = 0; index < items.length; index++) {
|
|
145
|
+
const item = items[index]
|
|
146
|
+
const key = keyOf(item, index)
|
|
147
|
+
if (seen.has(key)) {
|
|
148
|
+
console.warn(`Vobs: 检测到重复的列表 key: ${String(key)}`)
|
|
149
|
+
}
|
|
150
|
+
seen.add(key)
|
|
151
|
+
if (previous.has(key)) {
|
|
152
|
+
const entry = previous.get(key)!
|
|
153
|
+
previous.delete(key)
|
|
154
|
+
if (isPrimitiveItem(item) && !Object.is(entry.value, item)) {
|
|
155
|
+
// 原始类型项在编译产物中被静态捕获,无法通过 item 信号刷新视图:值变化时必须重建行。
|
|
156
|
+
nextEntries.push(createListEntry(item, index, key, renderItem))
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
entry.item.value = item
|
|
160
|
+
entry.value = item
|
|
161
|
+
nextEntries.push(entry)
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
nextEntries.push(createListEntry(item, index, key, renderItem))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return nextEntries
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function reconcileIndexed<T>(
|
|
171
|
+
items: readonly T[],
|
|
172
|
+
entries: Array<ListEntry<T>>,
|
|
173
|
+
renderItem: (item: T, index: number) => VobsNode
|
|
174
|
+
): Array<ListEntry<T>> {
|
|
175
|
+
const nextEntries: Array<ListEntry<T>> = []
|
|
176
|
+
for (let index = 0; index < items.length; index++) {
|
|
177
|
+
const item = items[index]
|
|
178
|
+
const entry = entries[index]
|
|
179
|
+
if (entry && isPrimitiveItem(item) && !Object.is(entry.value, item)) {
|
|
180
|
+
// 原始类型项被编译产物静态捕获,值变化时必须重建行;旧行由主循环统一 dispose。
|
|
181
|
+
nextEntries.push(createListEntry(item, index, index, renderItem))
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
if (entry) {
|
|
185
|
+
entry.item.value = item
|
|
186
|
+
entry.value = item
|
|
187
|
+
nextEntries.push(entry)
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
nextEntries.push(createListEntry(item, index, index, renderItem))
|
|
191
|
+
}
|
|
192
|
+
return nextEntries
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 原始类型(含 function)无法被 Proxy 响应化,只能整行重建。 */
|
|
196
|
+
function isPrimitiveItem(item: unknown): boolean {
|
|
197
|
+
return item === null || typeof item !== 'object'
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function createListEntry<T>(
|
|
201
|
+
item: T,
|
|
202
|
+
index: number,
|
|
203
|
+
key: unknown,
|
|
204
|
+
renderItem: (item: T, index: number) => VobsNode
|
|
205
|
+
): ListEntry<T> {
|
|
206
|
+
const owner = createOwner()
|
|
207
|
+
let itemSignal!: Signal<T>
|
|
208
|
+
let viewOwner!: Owner
|
|
209
|
+
let node!: VobsNode
|
|
210
|
+
owner.run(() => {
|
|
211
|
+
itemSignal = state(item)
|
|
212
|
+
viewOwner = createOwner()
|
|
213
|
+
node = viewOwner.run(() => renderItem(toReactiveItem(itemSignal, item), index))
|
|
214
|
+
})
|
|
215
|
+
associateNodeOwner(node, viewOwner)
|
|
216
|
+
return { key, node, owner, viewOwner, item: itemSignal, value: item, index }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function refreshListEntry<T>(
|
|
220
|
+
parent: Node,
|
|
221
|
+
entry: ListEntry<T>,
|
|
222
|
+
index: number,
|
|
223
|
+
renderItem: (item: T, index: number) => VobsNode
|
|
224
|
+
): void {
|
|
225
|
+
removeChild(parent, entry.node)
|
|
226
|
+
entry.index = index
|
|
227
|
+
const previousView = entry.viewOwner
|
|
228
|
+
entry.owner.run(() => {
|
|
229
|
+
entry.viewOwner = createOwner()
|
|
230
|
+
entry.node = entry.viewOwner.run(() => renderItem(
|
|
231
|
+
toReactiveItem(entry.item, entry.value),
|
|
232
|
+
index
|
|
233
|
+
))
|
|
234
|
+
})
|
|
235
|
+
// 旧视图的 effect 仍订阅着 item 信号,必须 dispose,否则会写已脱离的 DOM(幽灵更新)。
|
|
236
|
+
previousView.dispose()
|
|
237
|
+
associateNodeOwner(entry.node, entry.viewOwner)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function toReactiveItem<T>(item: Signal<T>, initialValue: T): T {
|
|
241
|
+
if (typeof initialValue !== 'object' || initialValue === null) {
|
|
242
|
+
return initialValue
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 代理目标使用创建时的原始对象:避免在列表 effect 内读取 item 信号造成自依赖;
|
|
246
|
+
// 属性读取始终转发到 item.value,在行内绑定 effect 中被正常追踪。
|
|
247
|
+
return new Proxy(initialValue as object, {
|
|
248
|
+
get(_target, property, receiver) {
|
|
249
|
+
return Reflect.get(item.value as object, property, receiver)
|
|
250
|
+
},
|
|
251
|
+
has(_target, property) {
|
|
252
|
+
return property in (item.value as object)
|
|
253
|
+
},
|
|
254
|
+
ownKeys() {
|
|
255
|
+
return Reflect.ownKeys(item.value as object)
|
|
256
|
+
},
|
|
257
|
+
getOwnPropertyDescriptor(_target, property) {
|
|
258
|
+
return Object.getOwnPropertyDescriptor(item.value as object, property)
|
|
259
|
+
}
|
|
260
|
+
}) as T
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function disposeEntry(parent: Node, entry: ListEntry<unknown>): void {
|
|
264
|
+
removeChild(parent, entry.node)
|
|
265
|
+
entry.owner.dispose()
|
|
266
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createFragment, type VobsNode } from './fragment'
|
|
2
|
+
import { insertBoundary, type BoundaryRetry } from './boundary'
|
|
3
|
+
import type { NodeFactory } from './dynamic'
|
|
4
|
+
|
|
5
|
+
export type ErrorBoundaryFallback = (
|
|
6
|
+
error: Error,
|
|
7
|
+
retry: BoundaryRetry
|
|
8
|
+
) => ReturnType<NodeFactory>
|
|
9
|
+
|
|
10
|
+
export interface ErrorBoundaryOptions {
|
|
11
|
+
children: NodeFactory
|
|
12
|
+
fallback: ErrorBoundaryFallback
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ErrorBoundaryProps {
|
|
16
|
+
children: NodeFactory
|
|
17
|
+
fallback: ErrorBoundaryFallback
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Inserts an error boundary without adding a wrapper node. It captures errors
|
|
22
|
+
* from its child render branch and child-owned effects, then renders fallback.
|
|
23
|
+
*/
|
|
24
|
+
export function insertErrorBoundary(
|
|
25
|
+
parent: Node,
|
|
26
|
+
anchor: Node | null,
|
|
27
|
+
options: ErrorBoundaryOptions
|
|
28
|
+
): void {
|
|
29
|
+
insertBoundary(parent, anchor, options)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Component-shaped API backed by the same no-wrapper host instruction. */
|
|
33
|
+
export function ErrorBoundary(props: ErrorBoundaryProps): VobsNode {
|
|
34
|
+
return createFragment((parent, anchor) => insertErrorBoundary(parent, anchor, props))
|
|
35
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { VobsError, formatVobsError, normalizeVobsError } from './error'
|
|
3
|
+
|
|
4
|
+
describe('Vobs error protocol', () => {
|
|
5
|
+
it('normalizes unknown throws with a stable code', () => {
|
|
6
|
+
const error = normalizeVobsError('offline', { code: 'VOBS_H001', layer: 'http' })
|
|
7
|
+
expect(error).toBeInstanceOf(VobsError)
|
|
8
|
+
expect(error.code).toBe('VOBS_H001')
|
|
9
|
+
expect(error.layer).toBe('http')
|
|
10
|
+
expect(error.message).toBe('offline')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('formats actionable development and production output', () => {
|
|
14
|
+
const error = new VobsError({
|
|
15
|
+
code: 'VOBS_R001',
|
|
16
|
+
message: 'Component render failed',
|
|
17
|
+
cause: new TypeError('missing data'),
|
|
18
|
+
fix: 'Guard the value before reading it.',
|
|
19
|
+
location: { file: 'src/App.tsx', line: 4, column: 9 }
|
|
20
|
+
})
|
|
21
|
+
const output = formatVobsError(error)
|
|
22
|
+
expect(output).toContain('Code: VOBS_R001')
|
|
23
|
+
expect(output).toContain('Location: src/App.tsx:4:9')
|
|
24
|
+
expect(output).toContain('Cause: TypeError: missing data')
|
|
25
|
+
expect(output).toContain('Fix: Guard the value before reading it.')
|
|
26
|
+
expect(formatVobsError(error, { environment: 'production' })).toBe('[Vobs VOBS_R001] Component render failed')
|
|
27
|
+
})
|
|
28
|
+
})
|