@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/src/ops.ts ADDED
@@ -0,0 +1,377 @@
1
+ // 编译产物调用的基础操作
2
+
3
+ import { createOwner, getCurrentOwner, setOwnerDebugName, type Owner } from '@vobs/reactivity'
4
+ import { isVobsFragment, type VobsNode } from './fragment'
5
+ import { describeDebugNode, getRuntimeDebugHooks, invokeRuntimeDebug, readDebugValue } from './debug'
6
+ import {
7
+ associateHmrInstance,
8
+ markHmrInstanceMounted,
9
+ registerHmrInstance,
10
+ type HmrInstance
11
+ } from './hmr'
12
+ import type { VobsRenderer } from './renderer'
13
+ import { setRef } from './ref'
14
+
15
+ type RuntimeRenderer = VobsRenderer<Node, Text, Element, Comment>
16
+
17
+ let currentRenderer: RuntimeRenderer | null = null
18
+ const nodeOwners = new WeakMap<object, Owner>()
19
+ interface EventBinding {
20
+ readonly handler: EventListener
21
+ readonly owner: Owner | null
22
+ readonly original: EventListener
23
+ }
24
+ const eventBindings = new WeakMap<object, Map<string, EventBinding>>()
25
+
26
+ export function setRenderer<
27
+ NodeType,
28
+ TextNode extends NodeType,
29
+ ElementNode extends NodeType,
30
+ CommentNode extends NodeType
31
+ >(renderer: VobsRenderer<NodeType, TextNode, ElementNode, CommentNode>): void {
32
+ // 编译产物仍使用 DOM 节点声明;实际宿主类型由应用提供的渲染器决定。
33
+ currentRenderer = renderer as unknown as RuntimeRenderer
34
+ }
35
+
36
+ export function getRenderer(): RuntimeRenderer {
37
+ if (!currentRenderer) {
38
+ throw new Error('渲染器未初始化')
39
+ }
40
+ return currentRenderer
41
+ }
42
+
43
+ export function createText(content: string): Text {
44
+ return getRenderer().createText(content)
45
+ }
46
+
47
+ export function createElement(tag: string): Element {
48
+ return getRenderer().createElement(tag)
49
+ }
50
+
51
+ export function createComment(content: string): Comment {
52
+ return getRenderer().createComment(content)
53
+ }
54
+
55
+ export function insertBefore(
56
+ parent: Node,
57
+ child: VobsNode,
58
+ anchor: VobsNode | null
59
+ ): void {
60
+ if (isVobsFragment(child)) {
61
+ child.mount(parent, isVobsFragment(anchor) ? anchor.start : anchor)
62
+ return
63
+ }
64
+ getRenderer().insertBefore(parent, child, isVobsFragment(anchor) ? anchor.start : anchor)
65
+ markHmrInstanceMounted(child, parent)
66
+ if (getRuntimeDebugHooks()) {
67
+ invokeRuntimeDebug('domMutation', {
68
+ operation: 'insert',
69
+ target: describeDebugNode(child),
70
+ parent: describeDebugNode(parent)
71
+ })
72
+ }
73
+ }
74
+
75
+ export function removeChild(
76
+ parent: Node,
77
+ child: VobsNode
78
+ ): void {
79
+ disposeNodeOwner(child)
80
+ if (isVobsFragment(child)) {
81
+ child.unmount(parent)
82
+ return
83
+ }
84
+ getRenderer().removeChild(parent, child)
85
+ if (getRuntimeDebugHooks()) {
86
+ invokeRuntimeDebug('domMutation', {
87
+ operation: 'remove',
88
+ target: describeDebugNode(child),
89
+ parent: describeDebugNode(parent)
90
+ })
91
+ }
92
+ }
93
+
94
+ export function setTextContent(
95
+ node: Text,
96
+ content: string
97
+ ): void {
98
+ const previousValue = getRuntimeDebugHooks()
99
+ ? readDebugValue(() => node.textContent)
100
+ : undefined
101
+ getRenderer().setTextContent(node, content)
102
+ if (getRuntimeDebugHooks()) {
103
+ invokeRuntimeDebug('domMutation', {
104
+ operation: 'text',
105
+ target: describeDebugNode(node),
106
+ previousValue,
107
+ nextValue: content
108
+ })
109
+ }
110
+ }
111
+
112
+ export function setProperty(
113
+ node: Element,
114
+ key: string,
115
+ value: unknown
116
+ ): void {
117
+ const previousValue = getRuntimeDebugHooks()
118
+ ? readDebugValue(() => Reflect.get(node, key))
119
+ : undefined
120
+ getRenderer().setProperty(node, key, value)
121
+ if (getRuntimeDebugHooks()) {
122
+ invokeRuntimeDebug('domMutation', {
123
+ operation: 'property',
124
+ target: describeDebugNode(node),
125
+ key,
126
+ previousValue,
127
+ nextValue: value
128
+ })
129
+ }
130
+ }
131
+
132
+ export function setAttribute(
133
+ node: Element,
134
+ key: string,
135
+ value: string
136
+ ): void {
137
+ const previousValue = getRuntimeDebugHooks()
138
+ ? readDebugValue(() => typeof node.getAttribute === 'function' ? node.getAttribute(key) : undefined)
139
+ : undefined
140
+ getRenderer().setAttribute(node, key, value)
141
+ if (getRuntimeDebugHooks()) {
142
+ invokeRuntimeDebug('domMutation', {
143
+ operation: 'attribute',
144
+ target: describeDebugNode(node),
145
+ key,
146
+ previousValue,
147
+ nextValue: value
148
+ })
149
+ }
150
+ }
151
+
152
+ export function spreadProps(node: Element, props: Record<string, unknown>): void {
153
+ for (const [key, value] of Object.entries(props)) {
154
+ if (key === 'key' || value === null || value === undefined) continue
155
+ if (key === 'ref') {
156
+ setRef(node, value)
157
+ continue
158
+ }
159
+ if (key.startsWith('on') && typeof value === 'function') addEventListener(node, key.slice(2).toLowerCase(), value as EventListener)
160
+ // property 键的 false 有语义(如 disabled={false} 必须清除),不能跳过;
161
+ // attribute 键的 false 表示“不设置”,与 HTML 语义一致。
162
+ else if (isPropertyKey(key)) setProperty(node, key, value)
163
+ else if (value === false) continue
164
+ else setAttribute(node, key === 'className' ? 'class' : key, key === 'style' && isStyleObject(value) ? formatStyle(value) : String(value))
165
+ }
166
+ }
167
+
168
+ /** Apply compile-time host properties in one renderer pass. */
169
+ export function setStaticProps(node: Element, props: Record<string, unknown>): void {
170
+ for (const [key, value] of Object.entries(props)) {
171
+ if (key === 'key' || key === 'ref' || key.startsWith('on')) continue
172
+ if (value === null || value === undefined) continue
173
+ if (isPropertyKey(key)) setProperty(node, key, value)
174
+ else if (value === false) continue
175
+ else setAttribute(node, key === 'className' ? 'class' : key, key === 'style' && isStyleObject(value) ? formatStyle(value) : String(value))
176
+ }
177
+ }
178
+
179
+ function isPropertyKey(key: string): boolean {
180
+ return key === 'value' || key === 'checked' || key === 'selected' || key === 'disabled'
181
+ || key === 'multiple' || key === 'readOnly' || key === 'required'
182
+ || key === 'autofocus' || key === 'hidden' || key === 'tabIndex'
183
+ }
184
+
185
+ function isStyleObject(value: unknown): value is Record<string, unknown> {
186
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
187
+ }
188
+
189
+ function formatStyle(value: Record<string, unknown>): string {
190
+ return Object.entries(value).filter(([, entry]) => entry !== null && entry !== undefined && entry !== false)
191
+ .map(([key, entry]) => `${key.replace(/[A-Z]/gu, match => `-${match.toLowerCase()}`)}:${String(entry)}`).join(';')
192
+ }
193
+
194
+ export function addEventListener(
195
+ node: Element,
196
+ event: string,
197
+ handler: EventListener
198
+ ): void {
199
+ const renderer = getRenderer()
200
+ const owner = getCurrentOwner()
201
+ let bindings = eventBindings.get(node)
202
+ if (!bindings) {
203
+ bindings = new Map()
204
+ eventBindings.set(node, bindings)
205
+ }
206
+ const previous = bindings.get(event)
207
+ if (previous && previous.original === handler && previous.owner === owner) return
208
+ if (previous) renderer.removeEventListener(node, event, previous.handler)
209
+ const listener = owner ? (reason: Event) => {
210
+ try {
211
+ owner.run(() => handler(reason))
212
+ } catch (error) {
213
+ const handled = owner.handleError(error)
214
+ invokeRuntimeDebug('error', {
215
+ error,
216
+ owner,
217
+ phase: 'event',
218
+ handled,
219
+ recovery: handled ? 'handled' : 'propagated'
220
+ })
221
+ if (!handled) throw error
222
+ }
223
+ } : handler
224
+ const binding: EventBinding = { handler: listener, owner, original: handler }
225
+ bindings.set(event, binding)
226
+ renderer.addEventListener(node, event, listener)
227
+ owner?.onDispose(() => {
228
+ if (bindings?.get(event) !== binding) return
229
+ bindings.delete(event)
230
+ renderer.removeEventListener(node, event, listener)
231
+ })
232
+ }
233
+
234
+ export function removeEventListener(
235
+ node: Element,
236
+ event: string,
237
+ handler: EventListener
238
+ ): void {
239
+ const renderer = getRenderer()
240
+ const binding = eventBindings.get(node)?.get(event)
241
+ renderer.removeEventListener(node, event, binding?.handler ?? handler)
242
+ eventBindings.get(node)?.delete(event)
243
+ }
244
+
245
+ export function clear(container: Node): void {
246
+ getRenderer().clear(container)
247
+ }
248
+
249
+ type VobsComponent = (...args: any[]) => VobsNode
250
+
251
+ type ComponentProps<Component extends VobsComponent> = Component extends (
252
+ props: infer Props
253
+ ) => VobsNode
254
+ ? NonNullable<Props> extends object ? NonNullable<Props> : Record<string, never>
255
+ : Record<string, never>
256
+
257
+ export function createComponent<Component extends VobsComponent>(
258
+ component: Component,
259
+ props: ComponentProps<Component>,
260
+ source?: VobsSourceLocation
261
+ ): VobsNode {
262
+ const owner = createOwner()
263
+ const componentName = (component as typeof component & { displayName?: string }).displayName
264
+ || component.name
265
+ || 'anonymous'
266
+ setOwnerDebugName(owner, source
267
+ ? `${componentName} (${source.file}:${source.line}:${source.column})`
268
+ : componentName)
269
+ owner.onError(reason => {
270
+ attachSourceLocation(reason, source)
271
+ attachComponentContext(reason, componentName, owner.id)
272
+ throw reason
273
+ })
274
+ let node: VobsNode
275
+ try {
276
+ node = owner.run(() => component(props))
277
+ } catch (error) {
278
+ owner.dispose()
279
+ attachSourceLocation(error, source)
280
+ attachComponentContext(error, componentName, owner.id)
281
+ throw error
282
+ }
283
+ associateNodeOwner(node, owner)
284
+ const hmrKey = (component as typeof component & { hmrKey?: string }).hmrKey
285
+ if (hmrKey) {
286
+ const instance: HmrInstance = {
287
+ node,
288
+ parent: null,
289
+ refresh(): void {
290
+ const previous = instance.node
291
+ const next = owner.run(() => component(props))
292
+ if (instance.parent && !isVobsFragment(previous) && !isVobsFragment(next)) {
293
+ getRenderer().insertBefore(instance.parent, next, previous)
294
+ getRenderer().removeChild(instance.parent, previous)
295
+ }
296
+ nodeOwners.delete(previous as object)
297
+ nodeOwners.set(next as object, owner)
298
+ associateHmrInstance(next, instance)
299
+ instance.node = next
300
+ }
301
+ }
302
+ associateHmrInstance(node, instance)
303
+ const separator = hmrKey.lastIndexOf(':')
304
+ const moduleId = separator < 0 ? hmrKey : hmrKey.slice(0, separator)
305
+ const cleanup = registerHmrInstance(moduleId, instance)
306
+ owner.onDispose(cleanup)
307
+ }
308
+ return node
309
+ }
310
+
311
+ export interface VobsSourceLocation {
312
+ readonly file: string
313
+ readonly line: number
314
+ readonly column: number
315
+ }
316
+
317
+ export interface VobsLocatedError extends Error {
318
+ readonly vobsSource?: VobsSourceLocation
319
+ readonly vobsComponent?: string
320
+ readonly vobsOwnerId?: string
321
+ }
322
+
323
+ function attachSourceLocation(reason: unknown, source: VobsSourceLocation | undefined): void {
324
+ if (!source || (!reason || (typeof reason !== 'object' && typeof reason !== 'function'))) return
325
+ const error = reason as VobsLocatedError
326
+ if (error.vobsSource) return
327
+ try {
328
+ Object.defineProperty(error, 'vobsSource', {
329
+ configurable: true,
330
+ enumerable: false,
331
+ value: source,
332
+ writable: false
333
+ })
334
+ } catch {
335
+ // Frozen third-party errors still propagate with their original details.
336
+ }
337
+ }
338
+
339
+ function attachComponentContext(reason: unknown, component: string, ownerId: string): void {
340
+ if (!reason || (typeof reason !== 'object' && typeof reason !== 'function')) return
341
+ const error = reason as VobsLocatedError
342
+ try {
343
+ if (!error.vobsComponent) Object.defineProperty(error, 'vobsComponent', { configurable: true, enumerable: false, value: component, writable: false })
344
+ if (!error.vobsOwnerId) Object.defineProperty(error, 'vobsOwnerId', { configurable: true, enumerable: false, value: ownerId, writable: false })
345
+ } catch {
346
+ // Frozen third-party errors still propagate with their original details.
347
+ }
348
+ }
349
+
350
+ export function createBlock(factory: () => VobsNode | null | undefined | false): VobsNode | null {
351
+ const owner = createOwner()
352
+ setOwnerDebugName(owner, 'dynamic')
353
+ let node: VobsNode | null | undefined | false
354
+ try {
355
+ node = owner.run(factory)
356
+ } catch (error) {
357
+ owner.dispose()
358
+ throw error
359
+ }
360
+ if (!node) {
361
+ owner.dispose()
362
+ return null
363
+ }
364
+ associateNodeOwner(node, owner)
365
+ return node
366
+ }
367
+
368
+ export function associateNodeOwner(node: VobsNode, owner: Owner): void {
369
+ nodeOwners.set(node, owner)
370
+ }
371
+
372
+ export function disposeNodeOwner(node: VobsNode): void {
373
+ const owner = nodeOwners.get(node)
374
+ if (!owner) return
375
+ nodeOwners.delete(node)
376
+ owner.dispose()
377
+ }
@@ -0,0 +1,45 @@
1
+ import { createFragment } from './fragment'
2
+ import { insertDynamic, type NodeFactory } from './dynamic'
3
+ import type { VobsNode } from './fragment'
4
+ import { createOwner } from '@vobs/reactivity'
5
+
6
+ export interface ProfilerRenderInfo {
7
+ readonly id: string
8
+ readonly phase: 'mount' | 'update'
9
+ readonly duration: number
10
+ readonly timestamp: number
11
+ }
12
+ export interface ProfilerOptions {
13
+ id: string
14
+ children: NodeFactory
15
+ onRender?: (info: ProfilerRenderInfo) => void
16
+ }
17
+ export interface ProfilerProps extends ProfilerOptions {}
18
+
19
+ export function insertProfiler(parent: Node, anchor: Node | null, options: ProfilerOptions): void {
20
+ const profiler = createOwner()
21
+ let mounted = false
22
+ profiler.run(() => {
23
+ insertDynamic(parent, anchor, () => {
24
+ const started = now()
25
+ const value = options.children()
26
+ const info: ProfilerRenderInfo = {
27
+ id: options.id,
28
+ phase: mounted ? 'update' : 'mount',
29
+ duration: Math.max(0, now() - started),
30
+ timestamp: Date.now()
31
+ }
32
+ mounted = true
33
+ try { options.onRender?.(info) } catch { /* profiler callbacks are isolated */ }
34
+ return value
35
+ })
36
+ })
37
+ }
38
+
39
+ export function Profiler(props: ProfilerProps): VobsNode {
40
+ return createFragment((parent, anchor) => insertProfiler(parent, anchor, props))
41
+ }
42
+
43
+ function now(): number {
44
+ return typeof performance !== 'undefined' && typeof performance.now === 'function' ? performance.now() : Date.now()
45
+ }
@@ -0,0 +1,54 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from 'vitest'
3
+ import { createDOMRenderer, setRenderer } from '@vobs/vobs'
4
+ import { setStaticProps, spreadProps } from './index'
5
+
6
+ setRenderer(createDOMRenderer())
7
+
8
+ describe('static and spread prop application', () => {
9
+ it('spreadProps applies false for property keys to clear them', () => {
10
+ const node = document.createElement('input') as HTMLInputElement
11
+ node.disabled = true
12
+ spreadProps(node, { disabled: false })
13
+ expect(node.disabled).toBe(false)
14
+
15
+ node.readOnly = true
16
+ spreadProps(node, { readOnly: false })
17
+ expect(node.readOnly).toBe(false)
18
+ })
19
+
20
+ it('spreadProps applies true for property keys', () => {
21
+ const node = document.createElement('input') as HTMLInputElement
22
+ spreadProps(node, { disabled: true })
23
+ expect(node.disabled).toBe(true)
24
+ })
25
+
26
+ it('spreadProps skips false for attribute keys', () => {
27
+ const node = document.createElement('div') as HTMLDivElement
28
+ spreadProps(node, { title: false, class: false, draggable: false })
29
+ expect(node.hasAttribute('title')).toBe(false)
30
+ expect(node.hasAttribute('class')).toBe(false)
31
+ })
32
+
33
+ it('spreadProps skips null and undefined for both key kinds', () => {
34
+ const node = document.createElement('input') as HTMLInputElement
35
+ node.disabled = true
36
+ spreadProps(node, { disabled: null, title: undefined })
37
+ // null/undefined 表示“不提供值”,保持现状语义
38
+ expect(node.disabled).toBe(true)
39
+ expect(node.hasAttribute('title')).toBe(false)
40
+ })
41
+
42
+ it('setStaticProps applies false for property keys', () => {
43
+ const node = document.createElement('input') as HTMLInputElement
44
+ setStaticProps(node, { disabled: false, checked: false } as Record<string, unknown>)
45
+ expect(node.disabled).toBe(false)
46
+ expect(node.checked).toBe(false)
47
+ })
48
+
49
+ it('setStaticProps skips false for attribute keys', () => {
50
+ const node = document.createElement('div') as HTMLDivElement
51
+ setStaticProps(node, { title: false } as Record<string, unknown>)
52
+ expect(node.hasAttribute('title')).toBe(false)
53
+ })
54
+ })
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createDOMRenderer, createVobs } from '@vobs/vobs'
3
+ import { createElement, createText, insertBefore, ref, setRef } from './index'
4
+
5
+ describe('ref', () => {
6
+ it('assigns host nodes and clears them when the app is destroyed', () => {
7
+ const target = document.createElement('div')
8
+ const nodeRef = ref<Element>()
9
+ const app = createVobs({
10
+ renderer: createDOMRenderer(),
11
+ render: () => {
12
+ const node = createElement('input')
13
+ setRef(node, nodeRef)
14
+ insertBefore(target, node, null)
15
+ return createText('')
16
+ }
17
+ })
18
+ app.mount(target)
19
+ expect(nodeRef.current).toBeInstanceOf(HTMLInputElement)
20
+ app.destroy()
21
+ expect(nodeRef.current).toBeNull()
22
+ })
23
+ })
package/src/ref.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { getCurrentOwner } from '@vobs/reactivity'
2
+
3
+ /** A mutable reference populated when a host node is mounted. */
4
+ export interface Ref<T extends object = Node> {
5
+ current: T | null
6
+ }
7
+
8
+ export type RefTarget<T extends object = Node> = Ref<T> | ((value: T | null) => void)
9
+
10
+ export function ref<T extends object = Node>(initialValue: T | null = null): Ref<T> {
11
+ return { current: initialValue }
12
+ }
13
+
14
+ /** Bind a host node to an object or callback ref and clear it with its Owner. */
15
+ export function setRef<T extends object>(node: T, target: unknown): void {
16
+ if (!isRefTarget<T>(target)) return
17
+ // SSR's serializable nodes are never exposed as live refs. Custom renderers
18
+ // may use arbitrary host objects, so only exclude the known SSR shape.
19
+ if (isSSRNode(node)) return
20
+ const owner = getCurrentOwner()
21
+ assignRef(target, node)
22
+ owner?.onDispose(() => {
23
+ // Do not clear a ref that has since been reassigned to another node.
24
+ if (isObjectRef(target) && target.current !== node) return
25
+ assignRef(target, null)
26
+ })
27
+ }
28
+
29
+ function isSSRNode(value: object): boolean {
30
+ const type = (value as { type?: unknown }).type
31
+ return (type === 'element' || type === 'text' || type === 'comment')
32
+ && !('nodeType' in value)
33
+ }
34
+
35
+ function isObjectRef<T extends object>(value: unknown): value is Ref<T> {
36
+ return Boolean(value && typeof value === 'object' && 'current' in value)
37
+ }
38
+
39
+ function isRefTarget<T extends object>(value: unknown): value is RefTarget<T> {
40
+ return isObjectRef<T>(value) || typeof value === 'function'
41
+ }
42
+
43
+ function assignRef<T extends object>(target: RefTarget<T>, value: T | null): void {
44
+ try {
45
+ if (typeof target === 'function') target(value)
46
+ else target.current = value
47
+ } catch {
48
+ // Ref callbacks are user code; never make mounting fail because of them.
49
+ }
50
+ }
@@ -0,0 +1,21 @@
1
+ export interface VobsRenderer<
2
+ NodeType = Node,
3
+ TextNode extends NodeType = NodeType,
4
+ ElementNode extends NodeType = NodeType,
5
+ CommentNode extends NodeType = NodeType
6
+ > {
7
+ createText(content: string): TextNode
8
+ createElement(tag: string): ElementNode
9
+ createComment(content: string): CommentNode
10
+ insertBefore(parent: NodeType, child: NodeType, anchor: NodeType | null): void
11
+ removeChild(parent: NodeType, child: NodeType): void
12
+ setTextContent(node: TextNode, content: string): void
13
+ setProperty(node: ElementNode, key: string, value: unknown): void
14
+ setAttribute(node: ElementNode, key: string, value: string): void
15
+ addEventListener(node: ElementNode, event: string, handler: EventListener): void
16
+ removeEventListener(node: ElementNode, event: string, handler: EventListener): void
17
+ nextSibling(node: NodeType): NodeType | null
18
+ clear(container: NodeType): void
19
+ beginHydration?(): void
20
+ completeHydration?(): void
21
+ }