@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
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
|
+
}
|
package/src/profiler.ts
ADDED
|
@@ -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
|
+
})
|
package/src/ref.test.ts
ADDED
|
@@ -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
|
+
}
|
package/src/renderer.ts
ADDED
|
@@ -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
|
+
}
|