@seed-ship/mcp-ui-solid 5.3.0 → 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.
Files changed (48) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/components/StreamingUIRenderer.cjs +106 -90
  3. package/dist/components/StreamingUIRenderer.cjs.map +1 -1
  4. package/dist/components/StreamingUIRenderer.d.ts +7 -0
  5. package/dist/components/StreamingUIRenderer.d.ts.map +1 -1
  6. package/dist/components/StreamingUIRenderer.js +107 -91
  7. package/dist/components/StreamingUIRenderer.js.map +1 -1
  8. package/dist/components/UIResourceRenderer.cjs +102 -83
  9. package/dist/components/UIResourceRenderer.cjs.map +1 -1
  10. package/dist/components/UIResourceRenderer.d.ts +23 -0
  11. package/dist/components/UIResourceRenderer.d.ts.map +1 -1
  12. package/dist/components/UIResourceRenderer.js +103 -84
  13. package/dist/components/UIResourceRenderer.js.map +1 -1
  14. package/dist/index.cjs +7 -0
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +3 -0
  17. package/dist/index.d.ts +3 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +7 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/node_modules/.pnpm/{dompurify@3.3.3 → dompurify@3.4.1}/node_modules/dompurify/dist/purify.es.cjs +114 -53
  22. package/dist/node_modules/.pnpm/dompurify@3.4.1/node_modules/dompurify/dist/purify.es.cjs.map +1 -0
  23. package/dist/node_modules/.pnpm/{dompurify@3.3.3 → dompurify@3.4.1}/node_modules/dompurify/dist/purify.es.js +114 -53
  24. package/dist/node_modules/.pnpm/dompurify@3.4.1/node_modules/dompurify/dist/purify.es.js.map +1 -0
  25. package/dist/utils/logger.cjs +26 -4
  26. package/dist/utils/logger.cjs.map +1 -1
  27. package/dist/utils/logger.d.ts +30 -3
  28. package/dist/utils/logger.d.ts.map +1 -1
  29. package/dist/utils/logger.js +27 -5
  30. package/dist/utils/logger.js.map +1 -1
  31. package/dist/utils/perf.cjs +34 -0
  32. package/dist/utils/perf.cjs.map +1 -0
  33. package/dist/utils/perf.d.ts +19 -0
  34. package/dist/utils/perf.d.ts.map +1 -0
  35. package/dist/utils/perf.js +34 -0
  36. package/dist/utils/perf.js.map +1 -0
  37. package/package.json +2 -2
  38. package/src/components/StreamingUIRenderer.tsx +54 -2
  39. package/src/components/UIResourceRenderer.errorMode.test.tsx +95 -0
  40. package/src/components/UIResourceRenderer.tsx +72 -4
  41. package/src/index.ts +7 -0
  42. package/src/utils/logger.test.ts +130 -0
  43. package/src/utils/logger.ts +60 -7
  44. package/src/utils/perf.test.ts +59 -0
  45. package/src/utils/perf.ts +50 -0
  46. package/tsconfig.tsbuildinfo +1 -1
  47. package/dist/node_modules/.pnpm/dompurify@3.3.3/node_modules/dompurify/dist/purify.es.cjs.map +0 -1
  48. package/dist/node_modules/.pnpm/dompurify@3.3.3/node_modules/dompurify/dist/purify.es.js.map +0 -1
@@ -26,12 +26,20 @@ import { useStreamingUI, type UseStreamingUIOptions } from '../hooks/useStreamin
26
26
  import type { UIComponent, RendererError } from '../types'
27
27
  import { validateComponent } from '../services/validation'
28
28
  import { GenerativeUIErrorBoundary } from './GenerativeUIErrorBoundary'
29
+ import { markRenderStart, markRenderEnd } from '../utils/perf'
30
+ import type { ValidationErrorMode } from './UIResourceRenderer'
29
31
 
30
32
  export interface StreamingUIRendererProps extends UseStreamingUIOptions {
31
33
  class?: string
32
34
  showProgress?: boolean
33
35
  showMetadata?: boolean
34
36
  onRenderError?: (error: RendererError) => void
37
+ /**
38
+ * How to react when a streamed component fails `validateComponent()`
39
+ * (v5.4.0). Defaults to `'block'` (full red error card — pre-v5.4.0
40
+ * behavior). See `ValidationErrorMode` in `UIResourceRenderer`.
41
+ */
42
+ errorMode?: ValidationErrorMode
35
43
  }
36
44
 
37
45
  /**
@@ -41,7 +49,12 @@ export interface StreamingUIRendererProps extends UseStreamingUIOptions {
41
49
  function StreamingComponentRenderer(props: {
42
50
  component: UIComponent
43
51
  onError?: (error: RendererError) => void
52
+ errorMode?: ValidationErrorMode
44
53
  }) {
54
+ // Performance marks (v5.4.0) — see utils/perf.ts
55
+ markRenderStart(props.component.id)
56
+ onMount(() => markRenderEnd(props.component.id))
57
+
45
58
  // Validate component before rendering
46
59
  const validation = validateComponent(props.component)
47
60
  if (!validation.valid) {
@@ -52,11 +65,46 @@ function StreamingComponentRenderer(props: {
52
65
  details: validation.errors,
53
66
  })
54
67
 
68
+ const mode: ValidationErrorMode = props.errorMode ?? 'block'
69
+ const firstError = validation.errors?.[0]?.message || 'Unknown validation error'
70
+
71
+ if (mode === 'silent') {
72
+ return null
73
+ }
74
+
75
+ if (mode === 'inline-warn') {
76
+ return (
77
+ <div
78
+ 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"
79
+ role="alert"
80
+ aria-label="Component validation warning"
81
+ title={firstError}
82
+ >
83
+ <svg
84
+ xmlns="http://www.w3.org/2000/svg"
85
+ class="w-3.5 h-3.5"
86
+ viewBox="0 0 24 24"
87
+ fill="none"
88
+ stroke="currentColor"
89
+ stroke-width="2"
90
+ stroke-linecap="round"
91
+ stroke-linejoin="round"
92
+ aria-hidden="true"
93
+ >
94
+ <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" />
95
+ <line x1="12" y1="9" x2="12" y2="13" />
96
+ <line x1="12" y1="17" x2="12.01" y2="17" />
97
+ </svg>
98
+ <span>Invalid {props.component.type}</span>
99
+ </div>
100
+ )
101
+ }
102
+
55
103
  return (
56
104
  <div class="w-full bg-error-subtle border border-border-error rounded-lg p-4">
57
105
  <p class="text-sm font-medium text-error-primary">Validation Error</p>
58
106
  <p class="text-xs text-text-secondary mt-1">
59
- {validation.errors?.[0]?.message || 'Unknown validation error'}
107
+ {firstError}
60
108
  </p>
61
109
  </div>
62
110
  )
@@ -214,7 +262,11 @@ export function StreamingUIRenderer(props: StreamingUIRendererProps) {
214
262
  `}
215
263
  style={`grid-column-start: ${component.position.colStart}; grid-column-end: ${component.position.colStart + component.position.colSpan}`}
216
264
  >
217
- <StreamingComponentRenderer component={component} onError={props.onRenderError} />
265
+ <StreamingComponentRenderer
266
+ component={component}
267
+ onError={props.onRenderError}
268
+ errorMode={props.errorMode}
269
+ />
218
270
  </div>
219
271
  )
220
272
  }}
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Tests for `errorMode` prop on `<UIResourceRenderer>` — v5.4.0 (B.3)
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach } from 'vitest'
6
+ import { render, cleanup } from '@solidjs/testing-library'
7
+ import { UIResourceRenderer } from './UIResourceRenderer'
8
+ import type { UIComponent, RendererError } from '../types'
9
+
10
+ // A component that fails `validateComponent()` — colStart=99 violates the 1-12 grid range
11
+ const invalidComponent: UIComponent = {
12
+ id: 'broken-1',
13
+ type: 'metric',
14
+ position: { colStart: 99, colSpan: 1 },
15
+ params: { value: 42 },
16
+ }
17
+
18
+ const validComponent: UIComponent = {
19
+ id: 'ok-1',
20
+ type: 'metric',
21
+ position: { colStart: 1, colSpan: 6 },
22
+ params: { title: 'OK', value: 42 },
23
+ }
24
+
25
+ describe('<UIResourceRenderer errorMode> — v5.4.0', () => {
26
+ beforeEach(() => {
27
+ cleanup()
28
+ })
29
+
30
+ it("default (no prop) = 'block': renders the red Validation Error card", () => {
31
+ const { getByText } = render(() => <UIResourceRenderer content={invalidComponent} />)
32
+ expect(getByText('Validation Error')).toBeTruthy()
33
+ })
34
+
35
+ it("errorMode='block' explicitly: same as default", () => {
36
+ const { getByText } = render(() => (
37
+ <UIResourceRenderer content={invalidComponent} errorMode="block" />
38
+ ))
39
+ expect(getByText('Validation Error')).toBeTruthy()
40
+ })
41
+
42
+ it("errorMode='inline-warn': renders compact yellow chip, no big red card", () => {
43
+ const { container, queryByText } = render(() => (
44
+ <UIResourceRenderer content={invalidComponent} errorMode="inline-warn" />
45
+ ))
46
+ expect(queryByText('Validation Error')).toBeNull()
47
+
48
+ const chip = container.querySelector('[role="alert"][aria-label="Component validation warning"]')
49
+ expect(chip).toBeTruthy()
50
+ expect(chip!.textContent).toContain('Invalid metric')
51
+ // tooltip carries the error message
52
+ expect(chip!.getAttribute('title')).toBeTruthy()
53
+ })
54
+
55
+ it("errorMode='silent': renders nothing in the slot, no error UI", () => {
56
+ const { container, queryByText, queryByRole } = render(() => (
57
+ <UIResourceRenderer content={invalidComponent} errorMode="silent" />
58
+ ))
59
+ expect(queryByText('Validation Error')).toBeNull()
60
+ expect(queryByRole('alert')).toBeNull()
61
+ // The slot wrapper is still in the DOM (grid layout) but has no error UI inside
62
+ expect(container.querySelector('[role="alert"]')).toBeNull()
63
+ })
64
+
65
+ it("onError still fires for ALL three modes (consumer can always log)", () => {
66
+ const errors: RendererError[] = []
67
+ const onError = (e: RendererError) => errors.push(e)
68
+
69
+ cleanup()
70
+ render(() => (
71
+ <UIResourceRenderer content={invalidComponent} errorMode="block" onError={onError} />
72
+ ))
73
+ cleanup()
74
+ render(() => (
75
+ <UIResourceRenderer content={invalidComponent} errorMode="inline-warn" onError={onError} />
76
+ ))
77
+ cleanup()
78
+ render(() => (
79
+ <UIResourceRenderer content={invalidComponent} errorMode="silent" onError={onError} />
80
+ ))
81
+
82
+ expect(errors.length).toBe(3)
83
+ expect(errors.every((e) => e.type === 'validation')).toBe(true)
84
+ expect(errors.every((e) => e.componentId === 'broken-1')).toBe(true)
85
+ })
86
+
87
+ it("valid components render normally regardless of errorMode", () => {
88
+ const { queryByText } = render(() => (
89
+ <UIResourceRenderer content={validComponent} errorMode="inline-warn" />
90
+ ))
91
+ // No error UI for a valid component
92
+ expect(queryByText('Validation Error')).toBeNull()
93
+ expect(queryByText('Invalid metric')).toBeNull()
94
+ })
95
+ })
@@ -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
- {validation.errors?.[0]?.message || 'Unknown validation error'}
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
+ })
@@ -1,12 +1,65 @@
1
1
  /**
2
2
  * Simple internal logger utility
3
3
  *
4
- * Provides basic logging functionality for the package.
5
- * Consumers can disable logging by setting NODE_ENV=production
6
- * or by implementing their own logging solution.
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
- const isDev = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
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 (isDev) {
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 (isDev) {
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 (isDev) {
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
+ }