@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vobs contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# @vobs/runtime
|
|
2
|
+
|
|
3
|
+
The renderer-agnostic runtime of the vobs framework: DOM operations, compiled bindings, dynamic insertion, list reconciliation, and lifecycle boundaries.
|
|
4
|
+
|
|
5
|
+
This package implements the operations the vobs compiler emits (`createElement`, `bindText`, `insertList`, ...) against a pluggable `VobsRenderer`, so the same component code runs under different rendering backends.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @vobs/runtime @vobs/reactivity
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
### Boundaries (user-facing)
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
import { ErrorBoundary, AsyncBoundary } from '@vobs/runtime'
|
|
19
|
+
|
|
20
|
+
<ErrorBoundary fallback={(error, retry) => <button onClick={retry}>Retry</button>}>
|
|
21
|
+
<RiskyView />
|
|
22
|
+
</ErrorBoundary>
|
|
23
|
+
|
|
24
|
+
<AsyncBoundary fallback={<Spinner />}>
|
|
25
|
+
<SlowView />
|
|
26
|
+
</AsyncBoundary>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Dynamic children and lists
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { effect, state } from '@vobs/reactivity'
|
|
33
|
+
import { insertDynamicValue, insertList, createFragment } from '@vobs/runtime'
|
|
34
|
+
|
|
35
|
+
const view = state(<span>hello</span>)
|
|
36
|
+
insertDynamicValue(parent, anchor, () => view.value) // swaps nodes reactively
|
|
37
|
+
|
|
38
|
+
const items = state(['a', 'b'])
|
|
39
|
+
insertList(parent, anchor, () => items.value, {
|
|
40
|
+
key: item => item // keyed reconciliation; indexed when omitted
|
|
41
|
+
})
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Reactive text binding:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { bindText } from '@vobs/runtime'
|
|
48
|
+
|
|
49
|
+
bindText(node, () => count.value)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Custom renderers
|
|
53
|
+
|
|
54
|
+
Implement `VobsRenderer` (node creation, insertion, removal, attribute/property/event application, clearing) and register it with `setRenderer`. All compiler-emitted operations route through it.
|
|
55
|
+
|
|
56
|
+
## API
|
|
57
|
+
|
|
58
|
+
| Signature | Description |
|
|
59
|
+
| --- | --- |
|
|
60
|
+
| `setRenderer(renderer)` / `getRenderer()` | Registers the active renderer backend. |
|
|
61
|
+
| `createElement(tag)` / `createText(text)` / `createComment(text)` | Node factories (hydration-aware). |
|
|
62
|
+
| `insertBefore(parent, node, anchor)` / `removeChild(parent, node)` | Tree mutation, fragment-aware. |
|
|
63
|
+
| `setAttribute(node, name, value)` / `setProperty(node, name, value)` | Attribute vs property writes; `isPropertyKey` gates the property whitelist. |
|
|
64
|
+
| `addEventListener(node, event, handler)` | Registers under the current owner — handlers get owner-scoped error isolation and are removed on owner dispose. |
|
|
65
|
+
| `spreadProps(node, props)` / `setStaticProps(node, props)` | Apply prop objects; property keys accept `false`, attribute keys skip it. |
|
|
66
|
+
| `bindText` / `bindAttribute` / `bindProperty` | Reactive one-way bindings to a node target. |
|
|
67
|
+
| `ref(target)` / `setRef(node, target)` | Ref plumbing for element access. |
|
|
68
|
+
| `insertDynamic(parent, anchor, factory)` / `insertDynamicValue(...)` | Reactive node swapping with full disposal of replaced subtrees. |
|
|
69
|
+
| `insertList(parent, anchor, factory, options?)` | Keyed/indexed list reconciliation with per-row owners. |
|
|
70
|
+
| `createFragment(render)` | Multi-root node container. |
|
|
71
|
+
| `ErrorBoundary` / `insertErrorBoundary` | Catches child render/effect errors with retry. |
|
|
72
|
+
| `insertBoundary` | Low-level boundary primitive (loading/error/empty switching). |
|
|
73
|
+
| `AsyncBoundary` / `insertAsyncBoundary` | Async view swapping with fallback. |
|
|
74
|
+
| `Profiler` / `insertProfiler` | Render timing instrumentation. |
|
|
75
|
+
| HMR exports (`createHmrStateStore`, `registerHmrInstance`, `updateHmrModule`, ...) | Hot-reload state preservation. |
|
|
76
|
+
|
|
77
|
+
## Types
|
|
78
|
+
|
|
79
|
+
`VobsRenderer`, `VobsNode`, `VobsFragment`, `DynamicChild`, `NodeFactory`, `Ref`, plus option types for each boundary.
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"license": "MIT",
|
|
3
|
+
"files": [
|
|
4
|
+
"src",
|
|
5
|
+
"README.md",
|
|
6
|
+
"LICENSE"
|
|
7
|
+
],
|
|
8
|
+
"name": "@vobs/runtime",
|
|
9
|
+
"version": "1.0.0",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "src/index.ts",
|
|
12
|
+
"types": "src/index.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.ts",
|
|
15
|
+
"./error": "./src/error.ts"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@vobs/reactivity": "1.0.0"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createOwner, state } from '@vobs/reactivity'
|
|
2
|
+
import { insertDynamic, type NodeFactory } from './dynamic'
|
|
3
|
+
import { createFragment, type VobsNode } from './fragment'
|
|
4
|
+
|
|
5
|
+
export type AsyncBoundaryView = ReturnType<NodeFactory> | NodeFactory
|
|
6
|
+
export type AsyncBoundaryFallback = (error: Error, retry: () => void) => ReturnType<NodeFactory>
|
|
7
|
+
export interface AsyncBoundaryOptions<T> {
|
|
8
|
+
promise: PromiseLike<T> | (() => PromiseLike<T>)
|
|
9
|
+
children: (value: T) => ReturnType<NodeFactory>
|
|
10
|
+
loading?: AsyncBoundaryView
|
|
11
|
+
fallback?: AsyncBoundaryFallback
|
|
12
|
+
resetKey?: () => unknown
|
|
13
|
+
}
|
|
14
|
+
export interface AsyncBoundaryProps<T> extends AsyncBoundaryOptions<T> {}
|
|
15
|
+
|
|
16
|
+
export function insertAsyncBoundary<T>(parent: Node, anchor: Node | null, options: AsyncBoundaryOptions<T>): void {
|
|
17
|
+
const boundary = createOwner()
|
|
18
|
+
boundary.run(() => {
|
|
19
|
+
const loading = state(true)
|
|
20
|
+
const data = state<T | undefined>(undefined)
|
|
21
|
+
const resolved = state(false)
|
|
22
|
+
const error = state<Error | null>(null)
|
|
23
|
+
let token = 0
|
|
24
|
+
let previousKey: unknown
|
|
25
|
+
let initialized = false
|
|
26
|
+
let fallbackActive = false
|
|
27
|
+
boundary.onError(reason => {
|
|
28
|
+
if (fallbackActive) throw reason
|
|
29
|
+
error.value = normalizeError(reason)
|
|
30
|
+
loading.value = false
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const start = (): void => {
|
|
34
|
+
const current = ++token
|
|
35
|
+
loading.value = true
|
|
36
|
+
error.value = null
|
|
37
|
+
data.value = undefined
|
|
38
|
+
resolved.value = false
|
|
39
|
+
let promise: PromiseLike<T>
|
|
40
|
+
try {
|
|
41
|
+
promise = typeof options.promise === 'function' ? options.promise() : options.promise
|
|
42
|
+
} catch (reason) {
|
|
43
|
+
if (current === token) {
|
|
44
|
+
loading.value = false
|
|
45
|
+
error.value = normalizeError(reason)
|
|
46
|
+
}
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
Promise.resolve(promise).then(value => {
|
|
50
|
+
if (current !== token || boundary.disposed) return
|
|
51
|
+
data.value = value
|
|
52
|
+
resolved.value = true
|
|
53
|
+
loading.value = false
|
|
54
|
+
}, reason => {
|
|
55
|
+
if (current !== token || boundary.disposed) return
|
|
56
|
+
error.value = normalizeError(reason)
|
|
57
|
+
loading.value = false
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const retry = (): void => start()
|
|
62
|
+
start()
|
|
63
|
+
insertDynamic(parent, anchor, () => {
|
|
64
|
+
const key = options.resetKey?.()
|
|
65
|
+
if (!initialized) {
|
|
66
|
+
initialized = true
|
|
67
|
+
previousKey = key
|
|
68
|
+
} else if (!Object.is(previousKey, key)) {
|
|
69
|
+
previousKey = key
|
|
70
|
+
start()
|
|
71
|
+
}
|
|
72
|
+
if (loading.value) {
|
|
73
|
+
fallbackActive = false
|
|
74
|
+
return resolveView(options.loading)
|
|
75
|
+
}
|
|
76
|
+
if (error.value) {
|
|
77
|
+
fallbackActive = true
|
|
78
|
+
return options.fallback?.(error.value, retry) ?? null
|
|
79
|
+
}
|
|
80
|
+
fallbackActive = false
|
|
81
|
+
return resolved.value ? options.children(data.value as T) : resolveView(options.loading)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function AsyncBoundary<T>(props: AsyncBoundaryProps<T>): VobsNode {
|
|
87
|
+
return createFragment((parent, anchor) => insertAsyncBoundary(parent, anchor, props))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resolveView(view: AsyncBoundaryView | undefined): ReturnType<NodeFactory> {
|
|
91
|
+
if (!view) return null
|
|
92
|
+
return typeof view === 'function' ? view() : view
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizeError(reason: unknown): Error {
|
|
96
|
+
return reason instanceof Error ? reason : new Error(String(reason))
|
|
97
|
+
}
|
package/src/bind.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// 动态绑定:将 Signal 绑定到 DOM 节点
|
|
2
|
+
|
|
3
|
+
import { effect } from '@vobs/reactivity'
|
|
4
|
+
import type { Signal } from '@vobs/reactivity'
|
|
5
|
+
import { setAttribute, setProperty, setTextContent } from './ops'
|
|
6
|
+
|
|
7
|
+
export type ValueSource<T> = Signal<T> | (() => T)
|
|
8
|
+
|
|
9
|
+
function readSource<T>(source: ValueSource<T>): T {
|
|
10
|
+
return typeof source === 'function' ? source() : source.value
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function bindText(
|
|
14
|
+
node: Text,
|
|
15
|
+
source: ValueSource<unknown>
|
|
16
|
+
): void {
|
|
17
|
+
effect(() => {
|
|
18
|
+
const value = readSource(source)
|
|
19
|
+
setTextContent(node, value === null || value === undefined || typeof value === 'boolean' ? '' : String(value))
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function bindAttribute(
|
|
24
|
+
node: Element,
|
|
25
|
+
key: string,
|
|
26
|
+
source: ValueSource<unknown>
|
|
27
|
+
): void {
|
|
28
|
+
effect(() => {
|
|
29
|
+
const value = readSource(source)
|
|
30
|
+
setAttribute(node, key, key === 'style' && value && typeof value === 'object' && !Array.isArray(value)
|
|
31
|
+
? Object.entries(value as Record<string, unknown>)
|
|
32
|
+
.filter(([, entry]) => entry !== null && entry !== undefined && entry !== false)
|
|
33
|
+
.map(([name, entry]) => `${name.replace(/[A-Z]/gu, match => `-${match.toLowerCase()}`)}:${String(entry)}`)
|
|
34
|
+
.join(';')
|
|
35
|
+
: String(value))
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function bindProperty(
|
|
40
|
+
node: Element,
|
|
41
|
+
key: string,
|
|
42
|
+
source: ValueSource<unknown>
|
|
43
|
+
): void {
|
|
44
|
+
effect(() => {
|
|
45
|
+
setProperty(node, key, readSource(source))
|
|
46
|
+
})
|
|
47
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { createDOMRenderer, createText, createVobs, Profiler, AsyncBoundary, state } from '@vobs/vobs'
|
|
3
|
+
|
|
4
|
+
describe('AsyncBoundary and Profiler', () => {
|
|
5
|
+
it('renders loading then resolved content', async () => {
|
|
6
|
+
let resolve!: (value: string) => void
|
|
7
|
+
const promise = new Promise<string>(done => { resolve = done })
|
|
8
|
+
const host = document.createElement('div')
|
|
9
|
+
const app = createVobs({ renderer: createDOMRenderer(), render: () => AsyncBoundary({
|
|
10
|
+
promise,
|
|
11
|
+
loading: createText('loading'),
|
|
12
|
+
children: value => createText(value)
|
|
13
|
+
}) })
|
|
14
|
+
app.mount(host)
|
|
15
|
+
expect(host.textContent).toBe('loading')
|
|
16
|
+
resolve('ready')
|
|
17
|
+
await promise
|
|
18
|
+
await Promise.resolve()
|
|
19
|
+
expect(host.textContent).toBe('ready')
|
|
20
|
+
app.destroy()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('ignores stale Promise results after a resetKey change', async () => {
|
|
24
|
+
let resolveFirst!: (value: string) => void
|
|
25
|
+
let resolveSecond!: (value: string) => void
|
|
26
|
+
const first = new Promise<string>(done => { resolveFirst = done })
|
|
27
|
+
const second = new Promise<string>(done => { resolveSecond = done })
|
|
28
|
+
const key = state(0)
|
|
29
|
+
const host = document.createElement('div')
|
|
30
|
+
const app = createVobs({ renderer: createDOMRenderer(), render: () => AsyncBoundary({
|
|
31
|
+
promise: () => key.value === 0 ? first : second,
|
|
32
|
+
resetKey: () => key.value,
|
|
33
|
+
children: value => createText(value)
|
|
34
|
+
}) })
|
|
35
|
+
app.mount(host)
|
|
36
|
+
key.value = 1
|
|
37
|
+
app.update()
|
|
38
|
+
resolveFirst('stale')
|
|
39
|
+
resolveSecond('fresh')
|
|
40
|
+
await Promise.resolve()
|
|
41
|
+
await Promise.resolve()
|
|
42
|
+
expect(host.textContent).toBe('fresh')
|
|
43
|
+
app.destroy()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('reports mount and update phases', () => {
|
|
47
|
+
const events: string[] = []
|
|
48
|
+
const host = document.createElement('div')
|
|
49
|
+
const app = createVobs({ renderer: createDOMRenderer(), render: () => Profiler({
|
|
50
|
+
id: 'demo',
|
|
51
|
+
onRender: info => events.push(info.phase),
|
|
52
|
+
children: () => createText('content')
|
|
53
|
+
}) })
|
|
54
|
+
app.mount(host)
|
|
55
|
+
expect(events).toEqual(['mount'])
|
|
56
|
+
app.destroy()
|
|
57
|
+
vi.restoreAllMocks()
|
|
58
|
+
})
|
|
59
|
+
})
|
package/src/boundary.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createOwner, state } from '@vobs/reactivity'
|
|
2
|
+
import { invokeRuntimeDebug } from './debug'
|
|
3
|
+
import { insertDynamic, type NodeFactory } from './dynamic'
|
|
4
|
+
import { normalizeVobsError } from './error'
|
|
5
|
+
|
|
6
|
+
export type BoundaryRetry = () => void | Promise<unknown>
|
|
7
|
+
export type BoundaryFallback = (error: Error, retry: BoundaryRetry) => ReturnType<NodeFactory>
|
|
8
|
+
|
|
9
|
+
export interface BoundaryOptions {
|
|
10
|
+
children: NodeFactory
|
|
11
|
+
fallback: BoundaryFallback
|
|
12
|
+
onRetry?: () => void | Promise<unknown>
|
|
13
|
+
resetKey?: () => unknown
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Shared no-wrapper boundary protocol. The boundary owns its reactive error
|
|
18
|
+
* state and child branch, so replacing the branch also disposes its Owner.
|
|
19
|
+
*/
|
|
20
|
+
export function insertBoundary(
|
|
21
|
+
parent: Node,
|
|
22
|
+
anchor: Node | null,
|
|
23
|
+
options: BoundaryOptions
|
|
24
|
+
): void {
|
|
25
|
+
const boundary = createOwner()
|
|
26
|
+
boundary.run(() => {
|
|
27
|
+
const error = state<Error | null>(null)
|
|
28
|
+
let fallbackActive = false
|
|
29
|
+
let lastError: Error | null = null
|
|
30
|
+
let initialized = false
|
|
31
|
+
let previousKey: unknown
|
|
32
|
+
|
|
33
|
+
boundary.onError(reason => {
|
|
34
|
+
if (fallbackActive) throw reason
|
|
35
|
+
const normalized = normalizeVobsError(reason, {
|
|
36
|
+
code: 'VOBS_R001',
|
|
37
|
+
layer: 'runtime',
|
|
38
|
+
fix: '检查组件渲染逻辑,或在边界 fallback 中提供恢复操作。'
|
|
39
|
+
})
|
|
40
|
+
lastError = normalized
|
|
41
|
+
invokeRuntimeDebug('error', {
|
|
42
|
+
error: normalized,
|
|
43
|
+
owner: boundary,
|
|
44
|
+
phase: 'boundary',
|
|
45
|
+
handled: true,
|
|
46
|
+
recovery: 'fallback'
|
|
47
|
+
})
|
|
48
|
+
error.value = normalized
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const retry = (): void | Promise<unknown> => {
|
|
52
|
+
if (error.value) {
|
|
53
|
+
invokeRuntimeDebug('error', {
|
|
54
|
+
error: error.value,
|
|
55
|
+
owner: boundary,
|
|
56
|
+
phase: 'boundary',
|
|
57
|
+
handled: true,
|
|
58
|
+
recovery: 'retrying'
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
error.value = null
|
|
62
|
+
return options.onRetry?.()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
insertDynamic(parent, anchor, () => {
|
|
66
|
+
const nextKey = options.resetKey?.()
|
|
67
|
+
if (!initialized || !Object.is(previousKey, nextKey)) {
|
|
68
|
+
initialized = true
|
|
69
|
+
previousKey = nextKey
|
|
70
|
+
if (error.value) error.value = null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const currentError = error.value
|
|
74
|
+
if (!currentError) {
|
|
75
|
+
if (fallbackActive && lastError) {
|
|
76
|
+
invokeRuntimeDebug('error', {
|
|
77
|
+
error: lastError,
|
|
78
|
+
owner: boundary,
|
|
79
|
+
phase: 'boundary',
|
|
80
|
+
handled: true,
|
|
81
|
+
recovery: 'recovered'
|
|
82
|
+
})
|
|
83
|
+
lastError = null
|
|
84
|
+
}
|
|
85
|
+
fallbackActive = false
|
|
86
|
+
return options.children()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fallbackActive = true
|
|
90
|
+
return options.fallback(currentError, retry)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
}
|
package/src/debug.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { Owner } from '@vobs/reactivity'
|
|
2
|
+
|
|
3
|
+
export type RuntimeDebugEnvironment = 'client' | 'server'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Lightweight context copied onto debug events created synchronously inside a
|
|
7
|
+
* Router loader, Effect or SSR render. It is intentionally optional so the
|
|
8
|
+
* runtime remains useful without DevTools.
|
|
9
|
+
*/
|
|
10
|
+
export interface RuntimeDebugContext {
|
|
11
|
+
readonly environment?: RuntimeDebugEnvironment
|
|
12
|
+
readonly sessionId?: string
|
|
13
|
+
readonly route?: string
|
|
14
|
+
readonly navigationId?: number
|
|
15
|
+
readonly dataRequestId?: number
|
|
16
|
+
readonly updateId?: string
|
|
17
|
+
readonly effectId?: string
|
|
18
|
+
readonly source?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RuntimeHydrationMismatch {
|
|
22
|
+
readonly kind: 'missing-node' | 'extra-node' | 'position' | 'content'
|
|
23
|
+
readonly expected: string
|
|
24
|
+
readonly actual: string
|
|
25
|
+
readonly path: string
|
|
26
|
+
readonly message: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type RuntimeDomMutationOperation = 'text' | 'property' | 'attribute' | 'insert' | 'remove'
|
|
30
|
+
|
|
31
|
+
export interface RuntimeDomMutation {
|
|
32
|
+
readonly operation: RuntimeDomMutationOperation
|
|
33
|
+
readonly target: string
|
|
34
|
+
readonly parent?: string
|
|
35
|
+
readonly key?: string
|
|
36
|
+
readonly previousValue?: unknown
|
|
37
|
+
readonly nextValue?: unknown
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RuntimeErrorEvent {
|
|
41
|
+
readonly error: unknown
|
|
42
|
+
readonly owner: Owner
|
|
43
|
+
readonly phase: 'event' | 'boundary'
|
|
44
|
+
readonly handled: boolean
|
|
45
|
+
readonly recovery: 'propagated' | 'handled' | 'fallback' | 'retrying' | 'recovered'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface RuntimeDebugHooks {
|
|
49
|
+
domMutation?(mutation: RuntimeDomMutation): void
|
|
50
|
+
error?(event: RuntimeErrorEvent): void
|
|
51
|
+
hydrationMismatch?(event: RuntimeHydrationMismatch): void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let activeRuntimeDebugHooks: RuntimeDebugHooks | null = null
|
|
55
|
+
let activeRuntimeDebugContext: RuntimeDebugContext | null = null
|
|
56
|
+
|
|
57
|
+
export function setRuntimeDebugHooks(hooks: RuntimeDebugHooks | null): RuntimeDebugHooks | null {
|
|
58
|
+
const previous = activeRuntimeDebugHooks
|
|
59
|
+
activeRuntimeDebugHooks = hooks
|
|
60
|
+
return previous
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getRuntimeDebugHooks(): RuntimeDebugHooks | null {
|
|
64
|
+
return activeRuntimeDebugHooks
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getRuntimeDebugContext(): RuntimeDebugContext | null {
|
|
68
|
+
return activeRuntimeDebugContext
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Run a synchronous operation with trace context, preserving async renders. */
|
|
72
|
+
export function runWithRuntimeDebugContext<T>(context: RuntimeDebugContext, task: () => T): T {
|
|
73
|
+
const previous = activeRuntimeDebugContext
|
|
74
|
+
const next = { ...previous, ...context }
|
|
75
|
+
activeRuntimeDebugContext = next
|
|
76
|
+
let result: T
|
|
77
|
+
try {
|
|
78
|
+
result = task()
|
|
79
|
+
} catch (error) {
|
|
80
|
+
activeRuntimeDebugContext = previous
|
|
81
|
+
throw error
|
|
82
|
+
}
|
|
83
|
+
if (isPromiseLike(result)) {
|
|
84
|
+
return Promise.resolve(result).finally(() => {
|
|
85
|
+
if (activeRuntimeDebugContext === next) activeRuntimeDebugContext = previous
|
|
86
|
+
}) as T
|
|
87
|
+
}
|
|
88
|
+
activeRuntimeDebugContext = previous
|
|
89
|
+
return result
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Keep a context active across callback boundaries such as Effect execution. */
|
|
93
|
+
export function pushRuntimeDebugContext(context: RuntimeDebugContext): () => void {
|
|
94
|
+
const previous = activeRuntimeDebugContext
|
|
95
|
+
activeRuntimeDebugContext = { ...previous, ...context }
|
|
96
|
+
let restored = false
|
|
97
|
+
return () => {
|
|
98
|
+
if (restored) return
|
|
99
|
+
restored = true
|
|
100
|
+
activeRuntimeDebugContext = previous
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function invokeRuntimeDebug<K extends keyof RuntimeDebugHooks>(
|
|
105
|
+
name: K,
|
|
106
|
+
...args: Parameters<NonNullable<RuntimeDebugHooks[K]>>
|
|
107
|
+
): void {
|
|
108
|
+
const callback = activeRuntimeDebugHooks?.[name] as ((...values: unknown[]) => void) | undefined
|
|
109
|
+
if (!callback) return
|
|
110
|
+
try {
|
|
111
|
+
callback(...args)
|
|
112
|
+
} catch {
|
|
113
|
+
// Debug tooling must never change runtime behavior.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function readDebugValue(read: () => unknown): unknown {
|
|
118
|
+
try {
|
|
119
|
+
return read()
|
|
120
|
+
} catch {
|
|
121
|
+
return '[Uninspectable]'
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
126
|
+
return Boolean(value) && (typeof value === 'object' || typeof value === 'function')
|
|
127
|
+
&& typeof (value as { then?: unknown }).then === 'function'
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function describeDebugNode(node: unknown): string {
|
|
131
|
+
if (!node || typeof node !== 'object') return 'node'
|
|
132
|
+
const value = node as {
|
|
133
|
+
readonly nodeName?: unknown
|
|
134
|
+
readonly tagName?: unknown
|
|
135
|
+
readonly id?: unknown
|
|
136
|
+
readonly className?: unknown
|
|
137
|
+
}
|
|
138
|
+
const name = typeof value.tagName === 'string'
|
|
139
|
+
? value.tagName.toLowerCase()
|
|
140
|
+
: typeof value.nodeName === 'string' ? value.nodeName.toLowerCase() : 'node'
|
|
141
|
+
const id = typeof value.id === 'string' && value.id ? `#${value.id}` : ''
|
|
142
|
+
const className = typeof value.className === 'string' && value.className
|
|
143
|
+
? `.${value.className.trim().split(/\s+/).filter(Boolean).join('.')}`
|
|
144
|
+
: ''
|
|
145
|
+
return `${name}${id}${className}`
|
|
146
|
+
}
|