@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.
@@ -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
+ })