@seed-ship/mcp-ui-solid 5.3.1 → 5.4.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/CHANGELOG.md +38 -0
- package/dist/components/StreamingUIRenderer.cjs +106 -90
- package/dist/components/StreamingUIRenderer.cjs.map +1 -1
- package/dist/components/StreamingUIRenderer.d.ts +7 -0
- package/dist/components/StreamingUIRenderer.d.ts.map +1 -1
- package/dist/components/StreamingUIRenderer.js +107 -91
- package/dist/components/StreamingUIRenderer.js.map +1 -1
- package/dist/components/UIResourceRenderer.cjs +101 -82
- package/dist/components/UIResourceRenderer.cjs.map +1 -1
- package/dist/components/UIResourceRenderer.d.ts +23 -0
- package/dist/components/UIResourceRenderer.d.ts.map +1 -1
- package/dist/components/UIResourceRenderer.js +102 -83
- package/dist/components/UIResourceRenderer.js.map +1 -1
- package/dist/index.cjs +7 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/utils/logger.cjs +26 -4
- package/dist/utils/logger.cjs.map +1 -1
- package/dist/utils/logger.d.ts +30 -3
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js +27 -5
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/perf.cjs +34 -0
- package/dist/utils/perf.cjs.map +1 -0
- package/dist/utils/perf.d.ts +19 -0
- package/dist/utils/perf.d.ts.map +1 -0
- package/dist/utils/perf.js +34 -0
- package/dist/utils/perf.js.map +1 -0
- package/package.json +1 -1
- package/src/components/StreamingUIRenderer.tsx +54 -2
- package/src/components/UIResourceRenderer.errorMode.test.tsx +95 -0
- package/src/components/UIResourceRenderer.tsx +72 -4
- package/src/index.ts +7 -0
- package/src/utils/logger.test.ts +130 -0
- package/src/utils/logger.ts +60 -7
- package/src/utils/perf.test.ts +59 -0
- package/src/utils/perf.ts +50 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -4,11 +4,28 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import DOMPurify from 'dompurify'
|
|
7
|
-
import { Component, createSignal, Show, For, createMemo, createEffect } from 'solid-js'
|
|
7
|
+
import { Component, createSignal, Show, For, createMemo, createEffect, onMount } from 'solid-js'
|
|
8
8
|
import { isServer } from 'solid-js/web'
|
|
9
9
|
import type { UIComponent, UILayout, RendererError, TableVirtualizeOptions } from '../types'
|
|
10
10
|
import { validateComponent, DEFAULT_RESOURCE_LIMITS, getIframeSandbox } from '../services/validation'
|
|
11
11
|
import { GenerativeUIErrorBoundary } from './GenerativeUIErrorBoundary'
|
|
12
|
+
import { markRenderStart, markRenderEnd } from '../utils/perf'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* How `<UIResourceRenderer>` reacts when `validateComponent()` rejects a
|
|
16
|
+
* component (v5.4.0).
|
|
17
|
+
*
|
|
18
|
+
* - `'block'` : full-slot red error card (default — backward compatible)
|
|
19
|
+
* - `'inline-warn'` : compact yellow chip in the slot, tooltip carries the
|
|
20
|
+
* error message — keeps the surrounding layout clean
|
|
21
|
+
* (e.g. inside a chat message)
|
|
22
|
+
* - `'silent'` : render nothing in the slot; `onError` still fires so the
|
|
23
|
+
* consumer can log/alert
|
|
24
|
+
*
|
|
25
|
+
* Runtime errors caught by `<GenerativeUIErrorBoundary>` are NOT affected by
|
|
26
|
+
* this prop — they always show the boundary's fallback UI.
|
|
27
|
+
*/
|
|
28
|
+
export type ValidationErrorMode = 'block' | 'inline-warn' | 'silent'
|
|
12
29
|
import { GridRenderer } from './GridRenderer'
|
|
13
30
|
import { FooterRenderer } from './FooterRenderer'
|
|
14
31
|
import { CarouselRenderer } from './CarouselRenderer'
|
|
@@ -93,6 +110,15 @@ export interface UIResourceRendererProps {
|
|
|
93
110
|
* Custom CSS class
|
|
94
111
|
*/
|
|
95
112
|
class?: string
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* How to react when a component fails `validateComponent()` (v5.4.0).
|
|
116
|
+
* Defaults to `'block'` (replaces the slot with a red error card —
|
|
117
|
+
* the pre-v5.4.0 behavior).
|
|
118
|
+
*
|
|
119
|
+
* @see ValidationErrorMode
|
|
120
|
+
*/
|
|
121
|
+
errorMode?: ValidationErrorMode
|
|
96
122
|
}
|
|
97
123
|
|
|
98
124
|
/**
|
|
@@ -1102,7 +1128,13 @@ function LinkRenderer(props: { component: UIComponent }) {
|
|
|
1102
1128
|
function ComponentRenderer(props: {
|
|
1103
1129
|
component: UIComponent
|
|
1104
1130
|
onError?: (error: RendererError) => void
|
|
1131
|
+
errorMode?: ValidationErrorMode
|
|
1105
1132
|
}) {
|
|
1133
|
+
// Performance marks — visible in Chrome DevTools "Performance" panel under
|
|
1134
|
+
// user timings. Always-on, SSR-safe (see utils/perf.ts).
|
|
1135
|
+
markRenderStart(props.component.id)
|
|
1136
|
+
onMount(() => markRenderEnd(props.component.id))
|
|
1137
|
+
|
|
1106
1138
|
// Validate component before rendering
|
|
1107
1139
|
const validation = validateComponent(props.component)
|
|
1108
1140
|
if (!validation.valid) {
|
|
@@ -1113,11 +1145,47 @@ function ComponentRenderer(props: {
|
|
|
1113
1145
|
details: validation.errors,
|
|
1114
1146
|
})
|
|
1115
1147
|
|
|
1148
|
+
const mode: ValidationErrorMode = props.errorMode ?? 'block'
|
|
1149
|
+
const firstError = validation.errors?.[0]?.message || 'Unknown validation error'
|
|
1150
|
+
|
|
1151
|
+
if (mode === 'silent') {
|
|
1152
|
+
return null
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
if (mode === 'inline-warn') {
|
|
1156
|
+
return (
|
|
1157
|
+
<div
|
|
1158
|
+
class="inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 text-xs text-yellow-800 dark:text-yellow-200"
|
|
1159
|
+
role="alert"
|
|
1160
|
+
aria-label="Component validation warning"
|
|
1161
|
+
title={firstError}
|
|
1162
|
+
>
|
|
1163
|
+
<svg
|
|
1164
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
1165
|
+
class="w-3.5 h-3.5"
|
|
1166
|
+
viewBox="0 0 24 24"
|
|
1167
|
+
fill="none"
|
|
1168
|
+
stroke="currentColor"
|
|
1169
|
+
stroke-width="2"
|
|
1170
|
+
stroke-linecap="round"
|
|
1171
|
+
stroke-linejoin="round"
|
|
1172
|
+
aria-hidden="true"
|
|
1173
|
+
>
|
|
1174
|
+
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
|
1175
|
+
<line x1="12" y1="9" x2="12" y2="13" />
|
|
1176
|
+
<line x1="12" y1="17" x2="12.01" y2="17" />
|
|
1177
|
+
</svg>
|
|
1178
|
+
<span>Invalid {props.component.type}</span>
|
|
1179
|
+
</div>
|
|
1180
|
+
)
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// mode === 'block' (default, pre-v5.4.0 behavior)
|
|
1116
1184
|
return (
|
|
1117
1185
|
<div class="w-full h-full bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
|
1118
1186
|
<p class="text-sm font-medium text-red-900 dark:text-red-100">Validation Error</p>
|
|
1119
1187
|
<p class="text-xs text-red-700 dark:text-red-300 mt-1">
|
|
1120
|
-
{
|
|
1188
|
+
{firstError}
|
|
1121
1189
|
</p>
|
|
1122
1190
|
</div>
|
|
1123
1191
|
)
|
|
@@ -1450,7 +1518,7 @@ export const UIResourceRenderer: Component<UIResourceRendererProps> = (props) =>
|
|
|
1450
1518
|
|
|
1451
1519
|
// Wrapper function for RenderContext (breaks circular dependency)
|
|
1452
1520
|
const renderComponent = (component: UIComponent, onError?: (error: RendererError) => void) => (
|
|
1453
|
-
<ComponentRenderer component={component} onError={onError} />
|
|
1521
|
+
<ComponentRenderer component={component} onError={onError} errorMode={props.errorMode} />
|
|
1454
1522
|
)
|
|
1455
1523
|
|
|
1456
1524
|
return (
|
|
@@ -1460,7 +1528,7 @@ export const UIResourceRenderer: Component<UIResourceRendererProps> = (props) =>
|
|
|
1460
1528
|
<For each={layoutData.components}>
|
|
1461
1529
|
{(component) => (
|
|
1462
1530
|
<div style={getGridStyleString(component)}>
|
|
1463
|
-
<ComponentRenderer component={component} onError={props.onError} />
|
|
1531
|
+
<ComponentRenderer component={component} onError={props.onError} errorMode={props.errorMode} />
|
|
1464
1532
|
</div>
|
|
1465
1533
|
)}
|
|
1466
1534
|
</For>
|
package/src/index.ts
CHANGED
|
@@ -89,6 +89,13 @@ export type {
|
|
|
89
89
|
GenerativeUIErrorBoundaryProps,
|
|
90
90
|
} from './components'
|
|
91
91
|
|
|
92
|
+
// Validation error mode (v5.4.0)
|
|
93
|
+
export type { ValidationErrorMode } from './components/UIResourceRenderer'
|
|
94
|
+
|
|
95
|
+
// Runtime debug mode + perf marks (v5.4.0)
|
|
96
|
+
export { setDebugMode, isDebugEnabled } from './utils/logger'
|
|
97
|
+
export { markRenderStart, markRenderEnd, PERF_PREFIX } from './utils/perf'
|
|
98
|
+
|
|
92
99
|
export type { DraggableGridItemProps } from './components/DraggableGridItem'
|
|
93
100
|
export type { ResizeHandleProps as ResizeHandleComponentProps } from './components/ResizeHandle'
|
|
94
101
|
export type { EditableUIResourceRendererProps } from './components/EditableUIResourceRenderer'
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for logger debug-mode controls — v5.4.0 (B.2)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
6
|
+
import { createLogger, setDebugMode, isDebugEnabled } from './logger'
|
|
7
|
+
|
|
8
|
+
describe('setDebugMode + isDebugEnabled (v5.4.0 — B.2)', () => {
|
|
9
|
+
let originalNodeEnv: string | undefined
|
|
10
|
+
let originalDebugEnv: string | undefined
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
originalNodeEnv = process.env.NODE_ENV
|
|
14
|
+
originalDebugEnv = process.env.MCP_UI_DEBUG
|
|
15
|
+
setDebugMode(null) // reset override
|
|
16
|
+
delete (globalThis as any).__MCP_UI_DEBUG__
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
process.env.NODE_ENV = originalNodeEnv
|
|
21
|
+
process.env.MCP_UI_DEBUG = originalDebugEnv
|
|
22
|
+
setDebugMode(null)
|
|
23
|
+
delete (globalThis as any).__MCP_UI_DEBUG__
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('default (NODE_ENV=test, no override): isDebugEnabled returns true', () => {
|
|
27
|
+
// Vitest sets NODE_ENV='test' by default — that's !== 'production' so dev mode is on
|
|
28
|
+
expect(isDebugEnabled()).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('NODE_ENV=production silences debug by default', () => {
|
|
32
|
+
process.env.NODE_ENV = 'production'
|
|
33
|
+
delete process.env.MCP_UI_DEBUG
|
|
34
|
+
expect(isDebugEnabled()).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('MCP_UI_DEBUG=true re-enables debug in production', () => {
|
|
38
|
+
process.env.NODE_ENV = 'production'
|
|
39
|
+
process.env.MCP_UI_DEBUG = 'true'
|
|
40
|
+
expect(isDebugEnabled()).toBe(true)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('globalThis.__MCP_UI_DEBUG__=true re-enables debug in production', () => {
|
|
44
|
+
process.env.NODE_ENV = 'production'
|
|
45
|
+
delete process.env.MCP_UI_DEBUG
|
|
46
|
+
;(globalThis as any).__MCP_UI_DEBUG__ = true
|
|
47
|
+
expect(isDebugEnabled()).toBe(true)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('setDebugMode(true) overrides NODE_ENV=production', () => {
|
|
51
|
+
process.env.NODE_ENV = 'production'
|
|
52
|
+
setDebugMode(true)
|
|
53
|
+
expect(isDebugEnabled()).toBe(true)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('setDebugMode(false) overrides NODE_ENV=development', () => {
|
|
57
|
+
process.env.NODE_ENV = 'development'
|
|
58
|
+
setDebugMode(false)
|
|
59
|
+
expect(isDebugEnabled()).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('setDebugMode(null) restores env-based detection', () => {
|
|
63
|
+
process.env.NODE_ENV = 'production'
|
|
64
|
+
setDebugMode(true)
|
|
65
|
+
expect(isDebugEnabled()).toBe(true)
|
|
66
|
+
setDebugMode(null)
|
|
67
|
+
expect(isDebugEnabled()).toBe(false)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('createLogger respects debug mode (v5.4.0)', () => {
|
|
72
|
+
let originalNodeEnv: string | undefined
|
|
73
|
+
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
originalNodeEnv = process.env.NODE_ENV
|
|
76
|
+
setDebugMode(null)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
process.env.NODE_ENV = originalNodeEnv
|
|
81
|
+
setDebugMode(null)
|
|
82
|
+
vi.restoreAllMocks()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('info/warn/debug are silent when debug is off', () => {
|
|
86
|
+
process.env.NODE_ENV = 'production'
|
|
87
|
+
setDebugMode(false)
|
|
88
|
+
|
|
89
|
+
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
|
|
90
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
91
|
+
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
|
|
92
|
+
|
|
93
|
+
const logger = createLogger('test')
|
|
94
|
+
logger.info('a')
|
|
95
|
+
logger.warn('b')
|
|
96
|
+
logger.debug('c')
|
|
97
|
+
|
|
98
|
+
expect(infoSpy).not.toHaveBeenCalled()
|
|
99
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
100
|
+
expect(debugSpy).not.toHaveBeenCalled()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('error always logs even when debug is off', () => {
|
|
104
|
+
process.env.NODE_ENV = 'production'
|
|
105
|
+
setDebugMode(false)
|
|
106
|
+
|
|
107
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
108
|
+
|
|
109
|
+
const logger = createLogger('test')
|
|
110
|
+
logger.error('boom', { id: 1 })
|
|
111
|
+
|
|
112
|
+
expect(errorSpy).toHaveBeenCalledOnce()
|
|
113
|
+
expect(errorSpy.mock.calls[0][0]).toContain('[@seed-ship/mcp-ui-solid:test]')
|
|
114
|
+
expect(errorSpy.mock.calls[0][0]).toContain('boom')
|
|
115
|
+
expect(errorSpy.mock.calls[0][0]).toContain('"id":1')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('toggling setDebugMode at runtime affects subsequent calls', () => {
|
|
119
|
+
process.env.NODE_ENV = 'production'
|
|
120
|
+
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
|
|
121
|
+
|
|
122
|
+
const logger = createLogger('toggle')
|
|
123
|
+
logger.info('off')
|
|
124
|
+
expect(infoSpy).not.toHaveBeenCalled()
|
|
125
|
+
|
|
126
|
+
setDebugMode(true)
|
|
127
|
+
logger.info('on')
|
|
128
|
+
expect(infoSpy).toHaveBeenCalledOnce()
|
|
129
|
+
})
|
|
130
|
+
})
|
package/src/utils/logger.ts
CHANGED
|
@@ -1,12 +1,65 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Simple internal logger utility
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Logging is enabled when EITHER:
|
|
5
|
+
* 1. `process.env.NODE_ENV !== 'production'` (dev build), OR
|
|
6
|
+
* 2. `process.env.MCP_UI_DEBUG === 'true'` (server-side opt-in for prod), OR
|
|
7
|
+
* 3. `globalThis.__MCP_UI_DEBUG__ === true` (browser-side runtime toggle), OR
|
|
8
|
+
* 4. `setDebugMode(true)` has been called from app code.
|
|
9
|
+
*
|
|
10
|
+
* `error` always logs regardless of mode.
|
|
11
|
+
*
|
|
12
|
+
* @see setDebugMode, isDebugEnabled — runtime controls (v5.4.0)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
declare global {
|
|
16
|
+
// Browser-side runtime flag — settable from devtools console:
|
|
17
|
+
// `globalThis.__MCP_UI_DEBUG__ = true`
|
|
18
|
+
// eslint-disable-next-line no-var
|
|
19
|
+
var __MCP_UI_DEBUG__: boolean | undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let debugOverride: boolean | null = null
|
|
23
|
+
|
|
24
|
+
function readEnvFlag(): boolean {
|
|
25
|
+
if (typeof process !== 'undefined' && process.env) {
|
|
26
|
+
if (process.env.MCP_UI_DEBUG === 'true') return true
|
|
27
|
+
if (process.env.NODE_ENV !== 'production') return true
|
|
28
|
+
}
|
|
29
|
+
if (typeof globalThis !== 'undefined' && globalThis.__MCP_UI_DEBUG__ === true) {
|
|
30
|
+
return true
|
|
31
|
+
}
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isDebugActive(): boolean {
|
|
36
|
+
if (debugOverride !== null) return debugOverride
|
|
37
|
+
return readEnvFlag()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Programmatically enable/disable verbose logging at runtime.
|
|
42
|
+
*
|
|
43
|
+
* Pass `null` to clear the override and fall back to env-based detection.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```ts
|
|
47
|
+
* import { setDebugMode } from '@seed-ship/mcp-ui-solid'
|
|
48
|
+
* setDebugMode(true) // turn on verbose logs
|
|
49
|
+
* setDebugMode(false) // turn off (overrides NODE_ENV=development)
|
|
50
|
+
* setDebugMode(null) // restore env-based behavior
|
|
51
|
+
* ```
|
|
7
52
|
*/
|
|
53
|
+
export function setDebugMode(enabled: boolean | null): void {
|
|
54
|
+
debugOverride = enabled
|
|
55
|
+
}
|
|
8
56
|
|
|
9
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Whether verbose logging is currently active (env + override combined).
|
|
59
|
+
*/
|
|
60
|
+
export function isDebugEnabled(): boolean {
|
|
61
|
+
return isDebugActive()
|
|
62
|
+
}
|
|
10
63
|
|
|
11
64
|
export interface Logger {
|
|
12
65
|
info(message: string, context?: Record<string, unknown>): void
|
|
@@ -39,13 +92,13 @@ function formatLogMessage(
|
|
|
39
92
|
export function createLogger(feature: string): Logger {
|
|
40
93
|
return {
|
|
41
94
|
info(message: string, context?: Record<string, unknown>) {
|
|
42
|
-
if (
|
|
95
|
+
if (isDebugActive()) {
|
|
43
96
|
console.info(formatLogMessage(feature, message, context))
|
|
44
97
|
}
|
|
45
98
|
},
|
|
46
99
|
|
|
47
100
|
warn(message: string, context?: Record<string, unknown>) {
|
|
48
|
-
if (
|
|
101
|
+
if (isDebugActive()) {
|
|
49
102
|
console.warn(formatLogMessage(feature, message, context))
|
|
50
103
|
}
|
|
51
104
|
},
|
|
@@ -56,7 +109,7 @@ export function createLogger(feature: string): Logger {
|
|
|
56
109
|
},
|
|
57
110
|
|
|
58
111
|
debug(message: string, context?: Record<string, unknown>) {
|
|
59
|
-
if (
|
|
112
|
+
if (isDebugActive()) {
|
|
60
113
|
console.debug(formatLogMessage(feature, message, context))
|
|
61
114
|
}
|
|
62
115
|
},
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for performance markers — v5.4.0 (B.4)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
6
|
+
import { markRenderStart, markRenderEnd, PERF_PREFIX } from './perf'
|
|
7
|
+
|
|
8
|
+
describe('performance marks (v5.4.0 — B.4)', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
if (typeof performance !== 'undefined' && typeof performance.clearMarks === 'function') {
|
|
11
|
+
performance.clearMarks()
|
|
12
|
+
}
|
|
13
|
+
if (typeof performance !== 'undefined' && typeof performance.clearMeasures === 'function') {
|
|
14
|
+
performance.clearMeasures()
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('PERF_PREFIX is the documented namespace', () => {
|
|
19
|
+
expect(PERF_PREFIX).toBe('mcp-ui:component:')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('markRenderStart writes a `:render-start` mark with the component id', () => {
|
|
23
|
+
markRenderStart('cmp-A')
|
|
24
|
+
const entries = performance.getEntriesByName('mcp-ui:component:cmp-A:render-start')
|
|
25
|
+
expect(entries.length).toBe(1)
|
|
26
|
+
expect(entries[0].entryType).toBe('mark')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('markRenderEnd writes both `:render-end` mark and a `:render` measure', () => {
|
|
30
|
+
markRenderStart('cmp-B')
|
|
31
|
+
markRenderEnd('cmp-B')
|
|
32
|
+
|
|
33
|
+
const endEntries = performance.getEntriesByName('mcp-ui:component:cmp-B:render-end')
|
|
34
|
+
expect(endEntries.length).toBe(1)
|
|
35
|
+
|
|
36
|
+
const measureEntries = performance.getEntriesByName('mcp-ui:component:cmp-B:render')
|
|
37
|
+
expect(measureEntries.length).toBe(1)
|
|
38
|
+
expect(measureEntries[0].entryType).toBe('measure')
|
|
39
|
+
expect(measureEntries[0].duration).toBeGreaterThanOrEqual(0)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('markRenderEnd without a preceding markRenderStart still writes the end mark (no throw)', () => {
|
|
43
|
+
expect(() => markRenderEnd('cmp-orphan')).not.toThrow()
|
|
44
|
+
const endEntries = performance.getEntriesByName('mcp-ui:component:cmp-orphan:render-end')
|
|
45
|
+
expect(endEntries.length).toBe(1)
|
|
46
|
+
// measure may or may not be created, but it must NOT crash the render path
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('mark functions are no-ops when performance is undefined (SSR-safe)', () => {
|
|
50
|
+
const originalPerf = (globalThis as any).performance
|
|
51
|
+
;(globalThis as any).performance = undefined
|
|
52
|
+
try {
|
|
53
|
+
expect(() => markRenderStart('ssr')).not.toThrow()
|
|
54
|
+
expect(() => markRenderEnd('ssr')).not.toThrow()
|
|
55
|
+
} finally {
|
|
56
|
+
;(globalThis as any).performance = originalPerf
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Performance markers for component renders (v5.4.0)
|
|
3
|
+
*
|
|
4
|
+
* Emits `performance.mark()` entries that show up automatically in Chrome
|
|
5
|
+
* DevTools "Performance" panel under user timings. Consumers can also
|
|
6
|
+
* query them via `performance.getEntriesByName(...)` for custom tracing.
|
|
7
|
+
*
|
|
8
|
+
* Naming convention :
|
|
9
|
+
* `mcp-ui:component:<id>:render-start`
|
|
10
|
+
* `mcp-ui:component:<id>:render-end`
|
|
11
|
+
* `mcp-ui:component:<id>:render` (a `measure` between the two)
|
|
12
|
+
*
|
|
13
|
+
* Always-on: marks are cheap (sub-microsecond) and only matter when a
|
|
14
|
+
* profiler is recording. SSR-safe (`performance` is guarded).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const PERF_PREFIX = 'mcp-ui:component:'
|
|
18
|
+
|
|
19
|
+
function hasPerf(): boolean {
|
|
20
|
+
return typeof performance !== 'undefined' && typeof performance.mark === 'function'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function markRenderStart(componentId: string): void {
|
|
24
|
+
if (!hasPerf()) return
|
|
25
|
+
try {
|
|
26
|
+
performance.mark(`${PERF_PREFIX}${componentId}:render-start`)
|
|
27
|
+
} catch {
|
|
28
|
+
// Ignore — performance.mark can throw on malformed names; not worth crashing the render.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function markRenderEnd(componentId: string): void {
|
|
33
|
+
if (!hasPerf()) return
|
|
34
|
+
try {
|
|
35
|
+
performance.mark(`${PERF_PREFIX}${componentId}:render-end`)
|
|
36
|
+
if (typeof performance.measure === 'function') {
|
|
37
|
+
try {
|
|
38
|
+
performance.measure(
|
|
39
|
+
`${PERF_PREFIX}${componentId}:render`,
|
|
40
|
+
`${PERF_PREFIX}${componentId}:render-start`,
|
|
41
|
+
`${PERF_PREFIX}${componentId}:render-end`
|
|
42
|
+
)
|
|
43
|
+
} catch {
|
|
44
|
+
// Start mark may be missing if the render path was short-circuited — ignore.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// Ignore.
|
|
49
|
+
}
|
|
50
|
+
}
|