@seed-ship/mcp-ui-solid 6.3.1 → 6.5.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 (58) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/dist/components/GraphRenderer.cjs +30 -24
  3. package/dist/components/GraphRenderer.cjs.map +1 -1
  4. package/dist/components/GraphRenderer.d.ts.map +1 -1
  5. package/dist/components/GraphRenderer.js +30 -24
  6. package/dist/components/GraphRenderer.js.map +1 -1
  7. package/dist/components/PortalDropdownMenu.cjs +82 -0
  8. package/dist/components/PortalDropdownMenu.cjs.map +1 -0
  9. package/dist/components/PortalDropdownMenu.d.ts +56 -0
  10. package/dist/components/PortalDropdownMenu.d.ts.map +1 -0
  11. package/dist/components/PortalDropdownMenu.js +82 -0
  12. package/dist/components/PortalDropdownMenu.js.map +1 -0
  13. package/dist/components/UIResourceRenderer.cjs +297 -263
  14. package/dist/components/UIResourceRenderer.cjs.map +1 -1
  15. package/dist/components/UIResourceRenderer.d.ts +20 -0
  16. package/dist/components/UIResourceRenderer.d.ts.map +1 -1
  17. package/dist/components/UIResourceRenderer.js +299 -265
  18. package/dist/components/UIResourceRenderer.js.map +1 -1
  19. package/dist/components/index.d.ts +2 -0
  20. package/dist/components/index.d.ts.map +1 -1
  21. package/dist/components.cjs +2 -0
  22. package/dist/components.cjs.map +1 -1
  23. package/dist/components.d.cts +2 -0
  24. package/dist/components.d.ts +2 -0
  25. package/dist/components.js +2 -0
  26. package/dist/components.js.map +1 -1
  27. package/dist/index.cjs +6 -0
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +5 -0
  30. package/dist/index.d.ts +5 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +6 -0
  33. package/dist/index.js.map +1 -1
  34. package/dist/utils/duplicate-mount-registry.cjs +27 -0
  35. package/dist/utils/duplicate-mount-registry.cjs.map +1 -0
  36. package/dist/utils/duplicate-mount-registry.d.ts +84 -0
  37. package/dist/utils/duplicate-mount-registry.d.ts.map +1 -0
  38. package/dist/utils/duplicate-mount-registry.js +27 -0
  39. package/dist/utils/duplicate-mount-registry.js.map +1 -0
  40. package/dist/utils/stable-key.cjs +41 -0
  41. package/dist/utils/stable-key.cjs.map +1 -0
  42. package/dist/utils/stable-key.d.ts +33 -0
  43. package/dist/utils/stable-key.d.ts.map +1 -0
  44. package/dist/utils/stable-key.js +41 -0
  45. package/dist/utils/stable-key.js.map +1 -0
  46. package/package.json +1 -1
  47. package/src/components/GraphRenderer.tsx +29 -20
  48. package/src/components/PortalDropdownMenu.test.tsx +113 -0
  49. package/src/components/PortalDropdownMenu.tsx +130 -0
  50. package/src/components/UIResourceRenderer.identity.test.tsx +161 -0
  51. package/src/components/UIResourceRenderer.tsx +85 -15
  52. package/src/components/index.ts +4 -0
  53. package/src/index.ts +10 -0
  54. package/src/utils/duplicate-mount-registry.test.ts +82 -0
  55. package/src/utils/duplicate-mount-registry.ts +113 -0
  56. package/src/utils/stable-key.test.ts +96 -0
  57. package/src/utils/stable-key.ts +91 -0
  58. package/tsconfig.tsbuildinfo +1 -1
package/src/index.ts CHANGED
@@ -44,6 +44,7 @@ export type { FeedbackInlineProps, FeedbackInlineContext } from './components/Fe
44
44
  export { ChatBusProvider, useChatBus } from './hooks/useChatBus'
45
45
  export { ChatPrompt } from './components/ChatPrompt'
46
46
  export { ElicitationForm } from './components/ElicitationForm'
47
+ export { PortalDropdownMenu } from './components/PortalDropdownMenu'
47
48
  export { ScratchpadPanel } from './components/ScratchpadPanel'
48
49
  export {
49
50
  dispatchScratchpad,
@@ -104,6 +105,14 @@ export { GraphRenderer, isG6Available, graphToMermaid, graphToJSON } from './com
104
105
  export { setDebugMode, isDebugEnabled } from './utils/logger'
105
106
  export { markRenderStart, markRenderEnd, PERF_PREFIX } from './utils/perf'
106
107
 
108
+ // Identity stability + opt-in observability (v6.5.0 — closes BRIEF-MCPUI-2026-05-10)
109
+ export { getUiResourceStableKey } from './utils/stable-key'
110
+ export { setDuplicateMountReporter } from './utils/duplicate-mount-registry'
111
+ export type {
112
+ DuplicateMountInfo,
113
+ DuplicateMountReporter,
114
+ } from './utils/duplicate-mount-registry'
115
+
107
116
  // Telemetry sink (B.5 — v5.6.0)
108
117
  export {
109
118
  MCPUITelemetryProvider,
@@ -126,6 +135,7 @@ export type { ExpandableWrapperProps } from './components/ExpandableWrapper'
126
135
  export type { ComponentToolbarProps, ToolbarAction, ToolbarIcon } from './components/ComponentToolbar'
127
136
  export type { ChatPromptProps } from './components/ChatPrompt'
128
137
  export type { ElicitationFormProps } from './components/ElicitationForm'
138
+ export type { PortalDropdownMenuProps } from './components/PortalDropdownMenu'
129
139
  export type { ScratchpadPanelProps } from './components/ScratchpadPanel'
130
140
  export type { VerifiedTextProps } from './components/VerifiedText'
131
141
  export type { DataPreviewSectionProps } from './components/DataPreviewSection'
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import {
3
+ setDuplicateMountReporter,
4
+ getDuplicateMountReporter,
5
+ _registerMount,
6
+ _unregisterMount,
7
+ _resetRegistry,
8
+ _getMountCount,
9
+ } from './duplicate-mount-registry'
10
+
11
+ describe('duplicate-mount-registry (v6.5.0)', () => {
12
+ beforeEach(() => {
13
+ _resetRegistry()
14
+ })
15
+
16
+ it('first mount returns count=1, no duplicate', () => {
17
+ const info = _registerMount('alpha')
18
+ expect(info.key).toBe('alpha')
19
+ expect(info.count).toBe(1)
20
+ expect(typeof info.firstMountedAt).toBe('number')
21
+ })
22
+
23
+ it('second concurrent mount returns count=2 (caller decides to warn)', () => {
24
+ _registerMount('beta')
25
+ const info = _registerMount('beta')
26
+ expect(info.count).toBe(2)
27
+ })
28
+
29
+ it('unregister decrements and cleans up at 0', () => {
30
+ _registerMount('gamma')
31
+ _registerMount('gamma')
32
+ expect(_getMountCount('gamma')).toBe(2)
33
+ _unregisterMount('gamma')
34
+ expect(_getMountCount('gamma')).toBe(1)
35
+ _unregisterMount('gamma')
36
+ expect(_getMountCount('gamma')).toBe(0)
37
+ })
38
+
39
+ it('unregister on unknown key is a no-op (no throw)', () => {
40
+ expect(() => _unregisterMount('does-not-exist')).not.toThrow()
41
+ })
42
+
43
+ it('firstMountedAt is preserved across the lifetime of the entry', () => {
44
+ const a = _registerMount('delta')
45
+ const b = _registerMount('delta')
46
+ expect(b.firstMountedAt).toBe(a.firstMountedAt)
47
+ })
48
+
49
+ it('firstMountedAt resets after a full cleanup cycle', async () => {
50
+ const first = _registerMount('epsilon')
51
+ _unregisterMount('epsilon')
52
+ // Tiny delay to guarantee a different Date.now() reading
53
+ await new Promise((r) => setTimeout(r, 2))
54
+ const second = _registerMount('epsilon')
55
+ expect(second.firstMountedAt).toBeGreaterThanOrEqual(first.firstMountedAt)
56
+ })
57
+
58
+ it('module reporter starts unwired (null)', () => {
59
+ expect(getDuplicateMountReporter()).toBeNull()
60
+ })
61
+
62
+ it('setDuplicateMountReporter wires + replaces + clears', () => {
63
+ const r1 = vi.fn()
64
+ setDuplicateMountReporter(r1)
65
+ expect(getDuplicateMountReporter()).toBe(r1)
66
+
67
+ const r2 = vi.fn()
68
+ setDuplicateMountReporter(r2)
69
+ expect(getDuplicateMountReporter()).toBe(r2)
70
+
71
+ setDuplicateMountReporter(null)
72
+ expect(getDuplicateMountReporter()).toBeNull()
73
+ })
74
+
75
+ it('different keys live in independent slots', () => {
76
+ _registerMount('zeta')
77
+ _registerMount('eta')
78
+ _registerMount('zeta')
79
+ expect(_getMountCount('zeta')).toBe(2)
80
+ expect(_getMountCount('eta')).toBe(1)
81
+ })
82
+ })
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Opt-in duplicate-mount registry (v6.5.0).
3
+ *
4
+ * Tracks how many times each `getUiResourceStableKey()` has been mounted
5
+ * concurrently across all `<UIResourceRenderer>` instances. When the same
6
+ * key is mounted more than once, registered reporters fire so consumers
7
+ * can detect double-render bugs in their parent framework.
8
+ *
9
+ * **Opt-in by design** : the registry is always populated (cheap), but
10
+ * notifications only fire when a consumer has wired one of the two opt-in
11
+ * paths :
12
+ * - module-level `setDuplicateMountReporter(fn)` (app-wide telemetry)
13
+ * - per-instance `<UIResourceRenderer onMountDuplicate={fn}>` prop
14
+ *
15
+ * **What this does NOT do** : visual deduplication. The renderer never
16
+ * hides or replaces a duplicate mount automatically — that would mask
17
+ * parent-framework bugs and could remove legitimate co-mounts (e.g. drawer
18
+ * + main panel showing the same card). Consumers who want dedup implement
19
+ * it on top of the reported events.
20
+ */
21
+
22
+ export interface DuplicateMountInfo {
23
+ /** Stable key from `getUiResourceStableKey(content)`. */
24
+ key: string
25
+ /**
26
+ * Current concurrent mount count. The reporter fires whenever this
27
+ * crosses 2 (i.e. on the 2nd, 3rd, etc. mount of the same key while
28
+ * earlier mounts are still alive).
29
+ */
30
+ count: number
31
+ /** `Date.now()` of the FIRST mount of this key (telemetry, not identity). */
32
+ firstMountedAt: number
33
+ }
34
+
35
+ export type DuplicateMountReporter = (info: DuplicateMountInfo) => void
36
+
37
+ const registry = new Map<string, { count: number; firstMountedAt: number }>()
38
+ let moduleReporter: DuplicateMountReporter | null = null
39
+
40
+ /**
41
+ * Wire a module-level reporter for duplicate mount events. Pass `null` to
42
+ * unwire. Only one module reporter at a time (replaces any previous one).
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * import { setDuplicateMountReporter } from '@seed-ship/mcp-ui-solid'
47
+ *
48
+ * setDuplicateMountReporter(({ key, count }) => {
49
+ * telemetry.warn('mcp-ui.duplicate-mount', { key, count })
50
+ * })
51
+ * ```
52
+ */
53
+ export function setDuplicateMountReporter(reporter: DuplicateMountReporter | null): void {
54
+ moduleReporter = reporter
55
+ }
56
+
57
+ /**
58
+ * Internal — read by `<UIResourceRenderer>` to dispatch on mount. Not part
59
+ * of the public API.
60
+ *
61
+ * @internal
62
+ */
63
+ export function getDuplicateMountReporter(): DuplicateMountReporter | null {
64
+ return moduleReporter
65
+ }
66
+
67
+ /**
68
+ * Internal — registers a mount for `key` and returns the resulting state.
69
+ * The caller decides whether to surface a notification based on `count > 1`.
70
+ *
71
+ * @internal
72
+ */
73
+ export function _registerMount(key: string): DuplicateMountInfo {
74
+ const entry = registry.get(key) ?? { count: 0, firstMountedAt: Date.now() }
75
+ entry.count += 1
76
+ registry.set(key, entry)
77
+ return { key, count: entry.count, firstMountedAt: entry.firstMountedAt }
78
+ }
79
+
80
+ /**
81
+ * Internal — undoes a prior `_registerMount(key)`. Removes the entry when
82
+ * the count reaches zero so the registry never leaks across mount/unmount
83
+ * cycles of unique keys.
84
+ *
85
+ * @internal
86
+ */
87
+ export function _unregisterMount(key: string): void {
88
+ const entry = registry.get(key)
89
+ if (!entry) return
90
+ entry.count -= 1
91
+ if (entry.count <= 0) registry.delete(key)
92
+ }
93
+
94
+ /**
95
+ * Internal — clears the registry and unwires any module reporter. Used by
96
+ * tests to ensure isolation between cases.
97
+ *
98
+ * @internal
99
+ */
100
+ export function _resetRegistry(): void {
101
+ registry.clear()
102
+ moduleReporter = null
103
+ }
104
+
105
+ /**
106
+ * Internal — read the current count for a key (0 if not mounted). Useful
107
+ * for tests and for consumers building their own debug overlays.
108
+ *
109
+ * @internal
110
+ */
111
+ export function _getMountCount(key: string): number {
112
+ return registry.get(key)?.count ?? 0
113
+ }
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { getUiResourceStableKey } from './stable-key'
3
+
4
+ describe('getUiResourceStableKey (v6.5.0)', () => {
5
+ it('returns layout.id verbatim when present and non-empty', () => {
6
+ const layout = { id: 'dashboard-2024-Q3', components: [], grid: { columns: 12, gap: '1rem' } }
7
+ expect(getUiResourceStableKey(layout)).toBe('dashboard-2024-Q3')
8
+ })
9
+
10
+ it('returns component.id verbatim when present and non-empty', () => {
11
+ const component = { id: 'chart-revenue', type: 'chart', params: {} }
12
+ expect(getUiResourceStableKey(component)).toBe('chart-revenue')
13
+ })
14
+
15
+ it('falls back to a content hash when id is missing', () => {
16
+ const bare = { type: 'chart', params: { type: 'bar', data: { labels: ['a'], datasets: [] } } }
17
+ const key = getUiResourceStableKey(bare)
18
+ expect(key).toMatch(/^[a-z0-9]{7}$/)
19
+ })
20
+
21
+ it('falls back to a content hash when id is empty string', () => {
22
+ const bare = { id: '', type: 'chart', params: {} }
23
+ const key = getUiResourceStableKey(bare)
24
+ expect(key).toMatch(/^[a-z0-9]{7}$/)
25
+ })
26
+
27
+ it('produces the same key across calls for structurally identical payloads', () => {
28
+ const a = { type: 'chart', params: { foo: 1, bar: 2 } }
29
+ const b = { type: 'chart', params: { foo: 1, bar: 2 } }
30
+ expect(getUiResourceStableKey(a)).toBe(getUiResourceStableKey(b))
31
+ })
32
+
33
+ it('is independent of object key insertion order', () => {
34
+ const a = { type: 'chart', params: { foo: 1, bar: 2 } }
35
+ const b = { params: { bar: 2, foo: 1 }, type: 'chart' }
36
+ expect(getUiResourceStableKey(a)).toBe(getUiResourceStableKey(b))
37
+ })
38
+
39
+ it('produces different keys for different payloads', () => {
40
+ const a = { type: 'chart', params: { x: 1 } }
41
+ const b = { type: 'chart', params: { x: 2 } }
42
+ expect(getUiResourceStableKey(a)).not.toBe(getUiResourceStableKey(b))
43
+ })
44
+
45
+ it('ignores metadata.generatedAt (timestamp must not affect identity)', () => {
46
+ const a = { type: 'chart', params: { x: 1 }, metadata: { generatedAt: '2026-05-10T10:00:00Z', llmModel: 'opus' } }
47
+ const b = { type: 'chart', params: { x: 1 }, metadata: { generatedAt: '2026-05-10T11:00:00Z', llmModel: 'opus' } }
48
+ expect(getUiResourceStableKey(a)).toBe(getUiResourceStableKey(b))
49
+ })
50
+
51
+ it('still distinguishes payloads with different non-timestamp metadata', () => {
52
+ const a = { type: 'chart', params: { x: 1 }, metadata: { llmModel: 'opus' } }
53
+ const b = { type: 'chart', params: { x: 1 }, metadata: { llmModel: 'sonnet' } }
54
+ expect(getUiResourceStableKey(a)).not.toBe(getUiResourceStableKey(b))
55
+ })
56
+
57
+ it('skips undefined entries deterministically', () => {
58
+ const a = { type: 'chart', params: { x: 1, y: undefined } }
59
+ const b = { type: 'chart', params: { x: 1 } }
60
+ expect(getUiResourceStableKey(a)).toBe(getUiResourceStableKey(b))
61
+ })
62
+
63
+ it('handles nested arrays', () => {
64
+ const a = { type: 'composite', components: [{ type: 'metric' }, { type: 'chart' }] }
65
+ const b = { type: 'composite', components: [{ type: 'metric' }, { type: 'chart' }] }
66
+ expect(getUiResourceStableKey(a)).toBe(getUiResourceStableKey(b))
67
+ })
68
+
69
+ it('different array order yields different keys (order is semantic)', () => {
70
+ const a = { type: 'composite', components: [{ type: 'metric' }, { type: 'chart' }] }
71
+ const b = { type: 'composite', components: [{ type: 'chart' }, { type: 'metric' }] }
72
+ expect(getUiResourceStableKey(a)).not.toBe(getUiResourceStableKey(b))
73
+ })
74
+
75
+ it('handles primitives gracefully', () => {
76
+ expect(getUiResourceStableKey('a string')).toMatch(/^[a-z0-9]{7}$/)
77
+ expect(getUiResourceStableKey(42)).toMatch(/^[a-z0-9]{7}$/)
78
+ expect(getUiResourceStableKey(null)).toMatch(/^[a-z0-9]{7}$/)
79
+ })
80
+
81
+ it('keeps the explicit id even if other fields would hash differently', () => {
82
+ const a = { id: 'fixed', type: 'chart', params: { x: 1 } }
83
+ const b = { id: 'fixed', type: 'chart', params: { x: 999 } }
84
+ expect(getUiResourceStableKey(a)).toBe('fixed')
85
+ expect(getUiResourceStableKey(b)).toBe('fixed')
86
+ })
87
+
88
+ it('generated timestamp ids are NOT special-cased — passthrough is intentional', () => {
89
+ // If a consumer (incorrectly) injects `wrap-${Date.now()}` ids, they get
90
+ // unique keys per render. That's their responsibility — the helper only
91
+ // strips the `id` field when it's missing or empty.
92
+ const a = { id: 'wrap-1700000000000', type: 'chart', params: {} }
93
+ const b = { id: 'wrap-1700000000001', type: 'chart', params: {} }
94
+ expect(getUiResourceStableKey(a)).not.toBe(getUiResourceStableKey(b))
95
+ })
96
+ })
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Stable identity key for UIResource payloads (v6.5.0).
3
+ *
4
+ * Consumers need a way to derive a deterministic key from a layout/component
5
+ * payload — for `<For>` keys, dedup detection, telemetry correlation, etc.
6
+ *
7
+ * Spec semantics : `UILayout.id` and `UIComponent.id` are obligatoires for
8
+ * any well-formed payload. When they are present and non-empty, this helper
9
+ * returns them as-is. When they are missing (e.g. consumer passing a "bare"
10
+ * chart payload `{ type: 'chart', params: {...} }` without wrapping it in
11
+ * a layout), the helper derives a stable key from the *content* — never
12
+ * from a timestamp or counter.
13
+ *
14
+ * The hash is FNV-1a 32-bit on a deterministically stringified form of the
15
+ * payload (sorted keys, undefined entries skipped). This is intentionally
16
+ * synchronous and dependency-free so consumers can call it inside a Solid
17
+ * memo or render function without ceremony.
18
+ */
19
+
20
+ const FNV_OFFSET_BASIS = 0x811c9dc5
21
+ const FNV_PRIME = 0x01000193
22
+
23
+ function fnv1a(str: string): string {
24
+ let hash = FNV_OFFSET_BASIS
25
+ for (let i = 0; i < str.length; i++) {
26
+ hash ^= str.charCodeAt(i)
27
+ hash = Math.imul(hash, FNV_PRIME)
28
+ }
29
+ return (hash >>> 0).toString(36).padStart(7, '0')
30
+ }
31
+
32
+ /**
33
+ * Deterministic JSON-like serialization. Object keys are sorted ; entries
34
+ * with `undefined` values are skipped (mirroring `JSON.stringify` semantics
35
+ * but with a stable order). Used as the input to `fnv1a()`.
36
+ */
37
+ function stableStringify(value: unknown): string {
38
+ if (value === undefined) return 'undefined'
39
+ if (value === null || typeof value !== 'object') return JSON.stringify(value)
40
+ if (Array.isArray(value)) {
41
+ return '[' + value.map(stableStringify).join(',') + ']'
42
+ }
43
+ const obj = value as Record<string, unknown>
44
+ const keys = Object.keys(obj)
45
+ .sort()
46
+ .filter((k) => obj[k] !== undefined)
47
+ return (
48
+ '{' +
49
+ keys.map((k) => JSON.stringify(k) + ':' + stableStringify(obj[k])).join(',') +
50
+ '}'
51
+ )
52
+ }
53
+
54
+ /**
55
+ * Strip fields that should NOT contribute to identity :
56
+ * - top-level `id` : we're computing the absent identity
57
+ * - `metadata.generatedAt` : timestamp of generation, not of identity
58
+ */
59
+ function normalizeForHash(input: unknown): unknown {
60
+ if (!input || typeof input !== 'object') return input
61
+ const { id: _id, ...rest } = input as Record<string, unknown>
62
+ void _id
63
+ if (rest.metadata && typeof rest.metadata === 'object' && !Array.isArray(rest.metadata)) {
64
+ const meta = rest.metadata as Record<string, unknown>
65
+ const { generatedAt: _t, ...metaRest } = meta
66
+ void _t
67
+ rest.metadata = Object.keys(metaRest).length > 0 ? metaRest : undefined
68
+ }
69
+ return rest
70
+ }
71
+
72
+ /**
73
+ * Returns a stable identity key for a UIResource payload.
74
+ *
75
+ * - If `input.id` is a non-empty string, returns it verbatim. This is the
76
+ * path taken by well-formed payloads (cf. spec §Identity).
77
+ * - Otherwise, returns a 7-char base36 FNV-1a hash of the normalized
78
+ * content. Stable across renders, identical for structurally identical
79
+ * payloads.
80
+ *
81
+ * The hash is NOT cryptographic ; it's a dedup/correlation key. Collisions
82
+ * are theoretically possible but vanishingly rare for the payload shapes
83
+ * MCP-UI emits in practice.
84
+ */
85
+ export function getUiResourceStableKey(input: unknown): string {
86
+ if (input && typeof input === 'object') {
87
+ const id = (input as { id?: unknown }).id
88
+ if (typeof id === 'string' && id.length > 0) return id
89
+ }
90
+ return fnv1a(stableStringify(normalizeForHash(input)))
91
+ }