@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.
- package/CHANGELOG.md +156 -0
- package/dist/components/GraphRenderer.cjs +30 -24
- package/dist/components/GraphRenderer.cjs.map +1 -1
- package/dist/components/GraphRenderer.d.ts.map +1 -1
- package/dist/components/GraphRenderer.js +30 -24
- package/dist/components/GraphRenderer.js.map +1 -1
- package/dist/components/PortalDropdownMenu.cjs +82 -0
- package/dist/components/PortalDropdownMenu.cjs.map +1 -0
- package/dist/components/PortalDropdownMenu.d.ts +56 -0
- package/dist/components/PortalDropdownMenu.d.ts.map +1 -0
- package/dist/components/PortalDropdownMenu.js +82 -0
- package/dist/components/PortalDropdownMenu.js.map +1 -0
- package/dist/components/UIResourceRenderer.cjs +297 -263
- package/dist/components/UIResourceRenderer.cjs.map +1 -1
- package/dist/components/UIResourceRenderer.d.ts +20 -0
- package/dist/components/UIResourceRenderer.d.ts.map +1 -1
- package/dist/components/UIResourceRenderer.js +299 -265
- package/dist/components/UIResourceRenderer.js.map +1 -1
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components.cjs +2 -0
- package/dist/components.cjs.map +1 -1
- package/dist/components.d.cts +2 -0
- package/dist/components.d.ts +2 -0
- package/dist/components.js +2 -0
- package/dist/components.js.map +1 -1
- package/dist/index.cjs +6 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/utils/duplicate-mount-registry.cjs +27 -0
- package/dist/utils/duplicate-mount-registry.cjs.map +1 -0
- package/dist/utils/duplicate-mount-registry.d.ts +84 -0
- package/dist/utils/duplicate-mount-registry.d.ts.map +1 -0
- package/dist/utils/duplicate-mount-registry.js +27 -0
- package/dist/utils/duplicate-mount-registry.js.map +1 -0
- package/dist/utils/stable-key.cjs +41 -0
- package/dist/utils/stable-key.cjs.map +1 -0
- package/dist/utils/stable-key.d.ts +33 -0
- package/dist/utils/stable-key.d.ts.map +1 -0
- package/dist/utils/stable-key.js +41 -0
- package/dist/utils/stable-key.js.map +1 -0
- package/package.json +1 -1
- package/src/components/GraphRenderer.tsx +29 -20
- package/src/components/PortalDropdownMenu.test.tsx +113 -0
- package/src/components/PortalDropdownMenu.tsx +130 -0
- package/src/components/UIResourceRenderer.identity.test.tsx +161 -0
- package/src/components/UIResourceRenderer.tsx +85 -15
- package/src/components/index.ts +4 -0
- package/src/index.ts +10 -0
- package/src/utils/duplicate-mount-registry.test.ts +82 -0
- package/src/utils/duplicate-mount-registry.ts +113 -0
- package/src/utils/stable-key.test.ts +96 -0
- package/src/utils/stable-key.ts +91 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -22,6 +22,7 @@ import { Component, createSignal, onCleanup, onMount, Show, For } from 'solid-js
|
|
|
22
22
|
import type { UIComponent } from '../types'
|
|
23
23
|
import type { GraphComponentParams, GraphLayout, GraphNode, GraphEdge } from '@seed-ship/mcp-ui-spec'
|
|
24
24
|
import { ExpandableWrapper, useExpanded } from './ExpandableWrapper'
|
|
25
|
+
import { PortalDropdownMenu } from './PortalDropdownMenu'
|
|
25
26
|
|
|
26
27
|
// Module-scoped lazy import promise — first call triggers the dynamic
|
|
27
28
|
// import, subsequent calls reuse the resolved module.
|
|
@@ -153,6 +154,8 @@ export const GraphRenderer: Component<GraphRendererProps> = (props) => {
|
|
|
153
154
|
const [error, setError] = createSignal<string | undefined>()
|
|
154
155
|
const [exportMenuOpen, setExportMenuOpen] = createSignal(false)
|
|
155
156
|
let containerRef: HTMLDivElement | undefined
|
|
157
|
+
// v6.4.0 — trigger ref consumed by <PortalDropdownMenu> for positioning
|
|
158
|
+
let exportTriggerRef: HTMLButtonElement | undefined
|
|
156
159
|
// Loosely typed because G6 is a peer-optional — we don't pull its
|
|
157
160
|
// types into the bundle just to type a transient local handle.
|
|
158
161
|
let graphInstance: any | undefined
|
|
@@ -281,35 +284,41 @@ export const GraphRenderer: Component<GraphRendererProps> = (props) => {
|
|
|
281
284
|
{/* Export menu — top-right, mirrors TableRenderer's pattern */}
|
|
282
285
|
<div class="absolute right-2 top-2 z-10">
|
|
283
286
|
<button
|
|
287
|
+
ref={exportTriggerRef}
|
|
284
288
|
type="button"
|
|
285
289
|
onClick={() => setExportMenuOpen((v) => !v)}
|
|
286
290
|
class="px-2 py-1 text-xs bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
|
287
291
|
title="Export graph"
|
|
288
292
|
aria-label="Export graph"
|
|
293
|
+
aria-haspopup="menu"
|
|
289
294
|
aria-expanded={exportMenuOpen()}
|
|
290
295
|
>
|
|
291
296
|
Export ▾
|
|
292
297
|
</button>
|
|
293
|
-
<
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
298
|
+
<PortalDropdownMenu
|
|
299
|
+
open={exportMenuOpen()}
|
|
300
|
+
onClose={() => setExportMenuOpen(false)}
|
|
301
|
+
trigger={exportTriggerRef}
|
|
302
|
+
width={176}
|
|
303
|
+
class="text-xs"
|
|
304
|
+
>
|
|
305
|
+
<For each={[
|
|
306
|
+
{ label: 'Download PNG', onClick: handleExportPNG, hint: 'visual snapshot' },
|
|
307
|
+
{ label: 'Download Mermaid', onClick: handleExportMermaid, hint: 'markdown / GitHub' },
|
|
308
|
+
{ label: 'Download JSON', onClick: handleExportJSON, hint: 'raw data' },
|
|
309
|
+
]}>
|
|
310
|
+
{(item) => (
|
|
311
|
+
<button
|
|
312
|
+
type="button"
|
|
313
|
+
onClick={item.onClick}
|
|
314
|
+
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 border-b border-gray-100 dark:border-gray-700 last:border-b-0"
|
|
315
|
+
>
|
|
316
|
+
<div class="font-medium">{item.label}</div>
|
|
317
|
+
<div class="text-[10px] text-gray-500 dark:text-gray-400">{item.hint}</div>
|
|
318
|
+
</button>
|
|
319
|
+
)}
|
|
320
|
+
</For>
|
|
321
|
+
</PortalDropdownMenu>
|
|
313
322
|
</div>
|
|
314
323
|
|
|
315
324
|
<div
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v6.4.0 — Portal-mounted dropdown for table + graph Export menus.
|
|
3
|
+
*
|
|
4
|
+
* Coverage targets :
|
|
5
|
+
* 1. Menu mounts via <Portal> on document.body (not in trigger's parent)
|
|
6
|
+
* 2. Menu position is computed from the trigger's getBoundingClientRect
|
|
7
|
+
* 3. Click outside the menu closes it
|
|
8
|
+
* 4. Escape key closes it
|
|
9
|
+
* 5. Click on the trigger itself does not close (parent owns toggle)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
13
|
+
import { render, fireEvent, cleanup } from '@solidjs/testing-library'
|
|
14
|
+
import { createSignal } from 'solid-js'
|
|
15
|
+
import { PortalDropdownMenu } from './PortalDropdownMenu'
|
|
16
|
+
|
|
17
|
+
describe('PortalDropdownMenu (v6.4.0)', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
cleanup()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
function harness(initiallyOpen = true) {
|
|
23
|
+
const [open, setOpen] = createSignal(initiallyOpen)
|
|
24
|
+
let triggerRef: HTMLButtonElement | undefined
|
|
25
|
+
const result = render(() => (
|
|
26
|
+
<div id="harness-root">
|
|
27
|
+
<button ref={triggerRef} onClick={() => setOpen((v) => !v)}>
|
|
28
|
+
Open
|
|
29
|
+
</button>
|
|
30
|
+
<PortalDropdownMenu
|
|
31
|
+
open={open()}
|
|
32
|
+
onClose={() => setOpen(false)}
|
|
33
|
+
trigger={triggerRef}
|
|
34
|
+
width={144}
|
|
35
|
+
>
|
|
36
|
+
<button data-testid="menu-item">Item 1</button>
|
|
37
|
+
</PortalDropdownMenu>
|
|
38
|
+
</div>
|
|
39
|
+
))
|
|
40
|
+
return { ...result, getOpen: open, setOpen, getTrigger: () => triggerRef }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
it('mounts the menu on document.body, NOT inside the trigger parent', () => {
|
|
44
|
+
const { container } = harness(true)
|
|
45
|
+
// Menu should NOT be inside the harness root (it's portaled to body)
|
|
46
|
+
const harnessRoot = container.querySelector('#harness-root')
|
|
47
|
+
expect(harnessRoot).toBeTruthy()
|
|
48
|
+
expect(harnessRoot!.querySelector('[data-testid="menu-item"]')).toBeNull()
|
|
49
|
+
// But it IS in document.body
|
|
50
|
+
const menuItem = document.body.querySelector('[data-testid="menu-item"]')
|
|
51
|
+
expect(menuItem).toBeTruthy()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('does not render the menu when open=false', () => {
|
|
55
|
+
harness(false)
|
|
56
|
+
expect(document.body.querySelector('[data-testid="menu-item"]')).toBeNull()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('positions the menu using the trigger getBoundingClientRect (right-aligned, 4px below)', () => {
|
|
60
|
+
// Stub the trigger rect so we get deterministic coords in jsdom
|
|
61
|
+
const fakeRect = {
|
|
62
|
+
top: 100, bottom: 130, left: 200, right: 350,
|
|
63
|
+
width: 150, height: 30, x: 200, y: 100, toJSON: () => ({}),
|
|
64
|
+
} as DOMRect
|
|
65
|
+
// Patch getBoundingClientRect on HTMLElement.prototype just for this test
|
|
66
|
+
const orig = HTMLElement.prototype.getBoundingClientRect
|
|
67
|
+
HTMLElement.prototype.getBoundingClientRect = vi.fn(() => fakeRect)
|
|
68
|
+
try {
|
|
69
|
+
harness(true)
|
|
70
|
+
const menu = document.body.querySelector('[role="menu"]') as HTMLElement
|
|
71
|
+
expect(menu).toBeTruthy()
|
|
72
|
+
// Position: top = bottom + 4 = 134, left = right - width = 350 - 144 = 206
|
|
73
|
+
expect(menu.style.top).toBe('134px')
|
|
74
|
+
expect(menu.style.left).toBe('206px')
|
|
75
|
+
expect(menu.style.position).toBe('fixed')
|
|
76
|
+
} finally {
|
|
77
|
+
HTMLElement.prototype.getBoundingClientRect = orig
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('closes on outside click (mousedown on document.body outside menu)', () => {
|
|
82
|
+
const { getOpen } = harness(true)
|
|
83
|
+
expect(getOpen()).toBe(true)
|
|
84
|
+
// Click on a brand-new element appended to body, outside the menu
|
|
85
|
+
const stranger = document.createElement('div')
|
|
86
|
+
document.body.appendChild(stranger)
|
|
87
|
+
fireEvent.mouseDown(stranger)
|
|
88
|
+
expect(getOpen()).toBe(false)
|
|
89
|
+
document.body.removeChild(stranger)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('closes on Escape', () => {
|
|
93
|
+
const { getOpen } = harness(true)
|
|
94
|
+
expect(getOpen()).toBe(true)
|
|
95
|
+
fireEvent.keyDown(document, { key: 'Escape' })
|
|
96
|
+
expect(getOpen()).toBe(false)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('does not close when mousedown lands on the trigger (parent owns toggle)', () => {
|
|
100
|
+
const { getOpen, getTrigger } = harness(true)
|
|
101
|
+
expect(getOpen()).toBe(true)
|
|
102
|
+
fireEvent.mouseDown(getTrigger()!)
|
|
103
|
+
// Menu should still be open — trigger toggle is parent's responsibility
|
|
104
|
+
expect(getOpen()).toBe(true)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('does not close when mousedown lands inside the menu', () => {
|
|
108
|
+
const { getOpen } = harness(true)
|
|
109
|
+
const item = document.body.querySelector('[data-testid="menu-item"]') as HTMLElement
|
|
110
|
+
fireEvent.mouseDown(item)
|
|
111
|
+
expect(getOpen()).toBe(true)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PortalDropdownMenu (v6.4.0) — generic dropdown that mounts via
|
|
3
|
+
* `<Portal>` on `document.body` instead of an in-tree `position: absolute`
|
|
4
|
+
* sibling. Eliminates two pain points around the legacy in-tree pattern :
|
|
5
|
+
*
|
|
6
|
+
* 1. **`overflow: hidden` clipping** — when the trigger lives inside a
|
|
7
|
+
* chat bubble or a card with `overflow: hidden`, an absolutely
|
|
8
|
+
* positioned menu sibling gets clipped at the ancestor's boundary.
|
|
9
|
+
* Mounting on `document.body` escapes the clip stack entirely.
|
|
10
|
+
* 2. **`z-index` wars** — chat surfaces stack composer / message rails
|
|
11
|
+
* above the message list, and ancestor `z-index` creates a new
|
|
12
|
+
* stacking context that captures the in-tree menu. A portal is a
|
|
13
|
+
* sibling of the document, so a single `z-index: 9999` wins.
|
|
14
|
+
*
|
|
15
|
+
* The menu is positioned with `position: fixed` from the trigger's
|
|
16
|
+
* `getBoundingClientRect()`. We re-measure on `scroll` (capture phase, so
|
|
17
|
+
* nested scrollables also fire) and `resize` to keep the menu pinned
|
|
18
|
+
* while the user interacts with surrounding chrome.
|
|
19
|
+
*
|
|
20
|
+
* Close affordances : click outside, Escape, programmatic via `onClose`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { Component, JSX, Show, createSignal, createEffect, onCleanup } from 'solid-js'
|
|
24
|
+
import { Portal } from 'solid-js/web'
|
|
25
|
+
|
|
26
|
+
export interface PortalDropdownMenuProps {
|
|
27
|
+
/**
|
|
28
|
+
* Whether the menu is currently open. Controlled by the parent.
|
|
29
|
+
*/
|
|
30
|
+
open: boolean
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Called when the menu wants to close (outside click / Escape / item
|
|
34
|
+
* click — it's the parent's job to actually flip `open` to false).
|
|
35
|
+
*/
|
|
36
|
+
onClose: () => void
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Trigger element used as the positioning anchor. The menu's right
|
|
40
|
+
* edge is aligned to the trigger's right edge, top to its bottom + 4px.
|
|
41
|
+
*/
|
|
42
|
+
trigger: HTMLElement | undefined
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Menu width in pixels. Used to compute the left coordinate so the menu's
|
|
46
|
+
* right edge aligns with the trigger's right edge. Default : `144` (the
|
|
47
|
+
* legacy table menu width — `w-36`).
|
|
48
|
+
*/
|
|
49
|
+
width?: number
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Menu content. Wrapped in the rounded / shadowed container — keep
|
|
53
|
+
* children minimal (just the items).
|
|
54
|
+
*/
|
|
55
|
+
children: JSX.Element
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Optional additional class names for the menu container, appended after
|
|
59
|
+
* the default Tailwind classes. Useful to override width or padding.
|
|
60
|
+
*/
|
|
61
|
+
class?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const PortalDropdownMenu: Component<PortalDropdownMenuProps> = (props) => {
|
|
65
|
+
const [position, setPosition] = createSignal({ top: 0, left: 0 })
|
|
66
|
+
let menuRef: HTMLDivElement | undefined
|
|
67
|
+
|
|
68
|
+
const updatePosition = () => {
|
|
69
|
+
const t = props.trigger
|
|
70
|
+
if (!t) return
|
|
71
|
+
const rect = t.getBoundingClientRect()
|
|
72
|
+
const w = props.width ?? 144
|
|
73
|
+
setPosition({
|
|
74
|
+
top: rect.bottom + 4,
|
|
75
|
+
left: rect.right - w,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Measure once when the menu opens, then react to viewport changes.
|
|
80
|
+
createEffect(() => {
|
|
81
|
+
if (!props.open) return
|
|
82
|
+
updatePosition()
|
|
83
|
+
|
|
84
|
+
const onDown = (e: MouseEvent) => {
|
|
85
|
+
const target = e.target as Node
|
|
86
|
+
if (menuRef?.contains(target) || props.trigger?.contains(target)) return
|
|
87
|
+
props.onClose()
|
|
88
|
+
}
|
|
89
|
+
const onKey = (e: KeyboardEvent) => {
|
|
90
|
+
if (e.key === 'Escape') props.onClose()
|
|
91
|
+
}
|
|
92
|
+
const onScrollOrResize = () => updatePosition()
|
|
93
|
+
|
|
94
|
+
document.addEventListener('mousedown', onDown)
|
|
95
|
+
document.addEventListener('keydown', onKey)
|
|
96
|
+
// Capture phase so scrolls inside nested containers (e.g. chat virtual
|
|
97
|
+
// list) also re-position the menu. `passive: true` because we never
|
|
98
|
+
// preventDefault here.
|
|
99
|
+
window.addEventListener('scroll', onScrollOrResize, { capture: true, passive: true })
|
|
100
|
+
window.addEventListener('resize', onScrollOrResize)
|
|
101
|
+
|
|
102
|
+
onCleanup(() => {
|
|
103
|
+
document.removeEventListener('mousedown', onDown)
|
|
104
|
+
document.removeEventListener('keydown', onKey)
|
|
105
|
+
window.removeEventListener('scroll', onScrollOrResize, { capture: true })
|
|
106
|
+
window.removeEventListener('resize', onScrollOrResize)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<Show when={props.open}>
|
|
112
|
+
<Portal>
|
|
113
|
+
<div
|
|
114
|
+
ref={menuRef}
|
|
115
|
+
role="menu"
|
|
116
|
+
style={{
|
|
117
|
+
position: 'fixed',
|
|
118
|
+
top: `${position().top}px`,
|
|
119
|
+
left: `${position().left}px`,
|
|
120
|
+
'z-index': 9999,
|
|
121
|
+
width: `${props.width ?? 144}px`,
|
|
122
|
+
}}
|
|
123
|
+
class={`bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 text-sm ${props.class ?? ''}`}
|
|
124
|
+
>
|
|
125
|
+
{props.children}
|
|
126
|
+
</div>
|
|
127
|
+
</Portal>
|
|
128
|
+
</Show>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v6.5.0 — Identity stability + opt-in observability for <UIResourceRenderer>.
|
|
3
|
+
*
|
|
4
|
+
* Coverage targets :
|
|
5
|
+
* 1. Layout content gets `data-mcp-ui-layout-id` from layout.id
|
|
6
|
+
* 2. Layout content without id falls back to a content hash
|
|
7
|
+
* 3. Single-component content gets `data-mcp-ui-component-id` (no layout id)
|
|
8
|
+
* 4. Each rendered child carries `data-mcp-ui-component-id`
|
|
9
|
+
* 5. `onMountDuplicate` callback fires on the 2nd concurrent mount
|
|
10
|
+
* 6. Module-level reporter (`setDuplicateMountReporter`) fires on duplicate
|
|
11
|
+
* 7. Single mount fires no duplicate notification
|
|
12
|
+
* 8. Cleanup on unmount allows the same key to be re-mounted without warn
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
16
|
+
import { render, cleanup } from '@solidjs/testing-library'
|
|
17
|
+
import { UIResourceRenderer } from './UIResourceRenderer'
|
|
18
|
+
import {
|
|
19
|
+
setDuplicateMountReporter,
|
|
20
|
+
_resetRegistry,
|
|
21
|
+
_getMountCount,
|
|
22
|
+
} from '../utils/duplicate-mount-registry'
|
|
23
|
+
import { getUiResourceStableKey } from '../utils/stable-key'
|
|
24
|
+
|
|
25
|
+
const SIMPLE_TEXT_COMPONENT = {
|
|
26
|
+
id: 'text-comp-1',
|
|
27
|
+
type: 'text' as const,
|
|
28
|
+
position: { colStart: 1, colSpan: 12 },
|
|
29
|
+
params: { content: 'Hello' },
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const SIMPLE_LAYOUT = {
|
|
33
|
+
id: 'layout-1',
|
|
34
|
+
components: [SIMPLE_TEXT_COMPONENT],
|
|
35
|
+
grid: { columns: 12, gap: '1rem' },
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('UIResourceRenderer identity (v6.5.0)', () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
cleanup()
|
|
41
|
+
_resetRegistry()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('layout content emits data-mcp-ui-layout-id from layout.id', () => {
|
|
45
|
+
const { container } = render(() => <UIResourceRenderer content={SIMPLE_LAYOUT} />)
|
|
46
|
+
const wrapper = container.querySelector('[data-mcp-ui-layout-id="layout-1"]')
|
|
47
|
+
expect(wrapper).toBeTruthy()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('layout without id falls back to a content hash', () => {
|
|
51
|
+
const bareLayout = {
|
|
52
|
+
components: [SIMPLE_TEXT_COMPONENT],
|
|
53
|
+
grid: { columns: 12, gap: '1rem' },
|
|
54
|
+
} as any
|
|
55
|
+
const expectedKey = getUiResourceStableKey(bareLayout)
|
|
56
|
+
const { container } = render(() => <UIResourceRenderer content={bareLayout} />)
|
|
57
|
+
const wrapper = container.querySelector(`[data-mcp-ui-layout-id="${expectedKey}"]`)
|
|
58
|
+
expect(wrapper).toBeTruthy()
|
|
59
|
+
// Hash form (FNV-1a base36) is 7 chars
|
|
60
|
+
expect(expectedKey).toMatch(/^[a-z0-9]{7}$/)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('single-component content emits data-mcp-ui-component-id (no layout id)', () => {
|
|
64
|
+
const { container } = render(() => <UIResourceRenderer content={SIMPLE_TEXT_COMPONENT} />)
|
|
65
|
+
expect(container.querySelector('[data-mcp-ui-layout-id]')).toBeNull()
|
|
66
|
+
const wrappers = container.querySelectorAll('[data-mcp-ui-component-id="text-comp-1"]')
|
|
67
|
+
// Outer wrapper + inner per-component wrapper both carry the id
|
|
68
|
+
expect(wrappers.length).toBeGreaterThanOrEqual(1)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('each child component wrapper inside a layout carries data-mcp-ui-component-id', () => {
|
|
72
|
+
const layout = {
|
|
73
|
+
id: 'multi',
|
|
74
|
+
components: [
|
|
75
|
+
{ id: 'comp-a', type: 'text', position: { colStart: 1, colSpan: 6 }, params: { content: 'A' } },
|
|
76
|
+
{ id: 'comp-b', type: 'text', position: { colStart: 7, colSpan: 6 }, params: { content: 'B' } },
|
|
77
|
+
],
|
|
78
|
+
grid: { columns: 12, gap: '1rem' },
|
|
79
|
+
} as any
|
|
80
|
+
const { container } = render(() => <UIResourceRenderer content={layout} />)
|
|
81
|
+
expect(container.querySelector('[data-mcp-ui-component-id="comp-a"]')).toBeTruthy()
|
|
82
|
+
expect(container.querySelector('[data-mcp-ui-component-id="comp-b"]')).toBeTruthy()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('fires onMountDuplicate on the 2nd concurrent mount of the same key', () => {
|
|
86
|
+
const onDup = vi.fn()
|
|
87
|
+
render(() => (
|
|
88
|
+
<>
|
|
89
|
+
<UIResourceRenderer content={SIMPLE_LAYOUT} onMountDuplicate={onDup} />
|
|
90
|
+
<UIResourceRenderer content={SIMPLE_LAYOUT} onMountDuplicate={onDup} />
|
|
91
|
+
</>
|
|
92
|
+
))
|
|
93
|
+
// Only the 2nd mount triggers the callback (count crosses 2)
|
|
94
|
+
expect(onDup).toHaveBeenCalledTimes(1)
|
|
95
|
+
expect(onDup).toHaveBeenCalledWith(
|
|
96
|
+
expect.objectContaining({ key: 'layout-1', count: 2 })
|
|
97
|
+
)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('does NOT fire onMountDuplicate on a single mount', () => {
|
|
101
|
+
const onDup = vi.fn()
|
|
102
|
+
render(() => <UIResourceRenderer content={SIMPLE_LAYOUT} onMountDuplicate={onDup} />)
|
|
103
|
+
expect(onDup).not.toHaveBeenCalled()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('module-level setDuplicateMountReporter fires on the 2nd mount', () => {
|
|
107
|
+
const reporter = vi.fn()
|
|
108
|
+
setDuplicateMountReporter(reporter)
|
|
109
|
+
render(() => (
|
|
110
|
+
<>
|
|
111
|
+
<UIResourceRenderer content={SIMPLE_LAYOUT} />
|
|
112
|
+
<UIResourceRenderer content={SIMPLE_LAYOUT} />
|
|
113
|
+
</>
|
|
114
|
+
))
|
|
115
|
+
expect(reporter).toHaveBeenCalledTimes(1)
|
|
116
|
+
expect(reporter).toHaveBeenCalledWith(
|
|
117
|
+
expect.objectContaining({ key: 'layout-1', count: 2 })
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('mounts unique-key payloads independently (no false positives)', () => {
|
|
122
|
+
const onDup = vi.fn()
|
|
123
|
+
const reporter = vi.fn()
|
|
124
|
+
setDuplicateMountReporter(reporter)
|
|
125
|
+
const a = { ...SIMPLE_LAYOUT, id: 'layout-A' }
|
|
126
|
+
const b = { ...SIMPLE_LAYOUT, id: 'layout-B' }
|
|
127
|
+
render(() => (
|
|
128
|
+
<>
|
|
129
|
+
<UIResourceRenderer content={a} onMountDuplicate={onDup} />
|
|
130
|
+
<UIResourceRenderer content={b} onMountDuplicate={onDup} />
|
|
131
|
+
</>
|
|
132
|
+
))
|
|
133
|
+
expect(onDup).not.toHaveBeenCalled()
|
|
134
|
+
expect(reporter).not.toHaveBeenCalled()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('cleanup unregisters the mount so the registry never leaks', () => {
|
|
138
|
+
const { unmount } = render(() => <UIResourceRenderer content={SIMPLE_LAYOUT} />)
|
|
139
|
+
expect(_getMountCount('layout-1')).toBe(1)
|
|
140
|
+
unmount()
|
|
141
|
+
expect(_getMountCount('layout-1')).toBe(0)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('debugDuplicateMounts prop forces a console.warn even when global debug off', () => {
|
|
145
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
146
|
+
try {
|
|
147
|
+
render(() => (
|
|
148
|
+
<>
|
|
149
|
+
<UIResourceRenderer content={SIMPLE_LAYOUT} debugDuplicateMounts />
|
|
150
|
+
<UIResourceRenderer content={SIMPLE_LAYOUT} debugDuplicateMounts />
|
|
151
|
+
</>
|
|
152
|
+
))
|
|
153
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
154
|
+
'[mcp-ui] duplicate UIResourceRenderer mount',
|
|
155
|
+
expect.objectContaining({ key: 'layout-1', count: 2 })
|
|
156
|
+
)
|
|
157
|
+
} finally {
|
|
158
|
+
warnSpy.mockRestore()
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
})
|
|
@@ -10,6 +10,14 @@ import type { UIComponent, UILayout, RendererError, TableVirtualizeOptions } fro
|
|
|
10
10
|
import { validateComponent, DEFAULT_RESOURCE_LIMITS, getIframeSandbox } from '../services/validation'
|
|
11
11
|
import { GenerativeUIErrorBoundary } from './GenerativeUIErrorBoundary'
|
|
12
12
|
import { markRenderStart, markRenderEnd, PERF_PREFIX } from '../utils/perf'
|
|
13
|
+
import { isDebugEnabled } from '../utils/logger'
|
|
14
|
+
import { getUiResourceStableKey } from '../utils/stable-key'
|
|
15
|
+
import {
|
|
16
|
+
_registerMount,
|
|
17
|
+
_unregisterMount,
|
|
18
|
+
getDuplicateMountReporter,
|
|
19
|
+
type DuplicateMountInfo,
|
|
20
|
+
} from '../utils/duplicate-mount-registry'
|
|
13
21
|
import { useTelemetry } from '../context/MCPUITelemetryContext'
|
|
14
22
|
|
|
15
23
|
/**
|
|
@@ -41,6 +49,7 @@ import { CodeBlockRenderer } from './CodeBlockRenderer'
|
|
|
41
49
|
import { MapRenderer } from './MapRenderer'
|
|
42
50
|
import { GraphRenderer } from './GraphRenderer'
|
|
43
51
|
import { ExpandableWrapper, useExpanded } from './ExpandableWrapper'
|
|
52
|
+
import { PortalDropdownMenu } from './PortalDropdownMenu'
|
|
44
53
|
import { RenderProvider } from './RenderContext'
|
|
45
54
|
import { useAction } from '../hooks/useAction'
|
|
46
55
|
import { marked } from 'marked'
|
|
@@ -134,6 +143,27 @@ export interface UIResourceRendererProps {
|
|
|
134
143
|
* graph, map, video, carousel, image-gallery, code.
|
|
135
144
|
*/
|
|
136
145
|
toolbarVariant?: 'hover' | 'always-visible'
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Per-instance hook fired when this renderer mounts a content key that
|
|
149
|
+
* is already mounted elsewhere in the document (v6.5.0 — closes Demande 2
|
|
150
|
+
* of `BRIEF-MCPUI-2026-05-10.md`).
|
|
151
|
+
*
|
|
152
|
+
* The key comes from `getUiResourceStableKey(content)` — `content.id` if
|
|
153
|
+
* provided, else a content hash. The reporter fires every time the
|
|
154
|
+
* concurrent mount count crosses 2+ ; consumers decide what to do
|
|
155
|
+
* (`console.warn`, telemetry beacon, debug overlay, …). The renderer
|
|
156
|
+
* never deduplicates visually on its own.
|
|
157
|
+
*/
|
|
158
|
+
onMountDuplicate?: (info: DuplicateMountInfo) => void
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* When `true`, log duplicate mounts to `console.warn` from this instance
|
|
162
|
+
* even when the global `isDebugEnabled()` flag is off. Use to opt-in to
|
|
163
|
+
* console noise on a single suspect surface without flipping the global
|
|
164
|
+
* debug switch (v6.5.0).
|
|
165
|
+
*/
|
|
166
|
+
debugDuplicateMounts?: boolean
|
|
137
167
|
}
|
|
138
168
|
|
|
139
169
|
/**
|
|
@@ -755,6 +785,8 @@ function TableRenderer(props: {
|
|
|
755
785
|
|
|
756
786
|
// Export dropdown state
|
|
757
787
|
const [showExportMenu, setShowExportMenu] = createSignal(false)
|
|
788
|
+
// v6.4.0 — trigger ref consumed by <PortalDropdownMenu> for positioning
|
|
789
|
+
let exportTriggerRef: HTMLButtonElement | undefined
|
|
758
790
|
|
|
759
791
|
const handleExport = (format: string) => {
|
|
760
792
|
setShowExportMenu(false)
|
|
@@ -841,28 +873,34 @@ function TableRenderer(props: {
|
|
|
841
873
|
<Show when={exportable} fallback={<CopyButton getText={getTableCSV} title="Copy table (CSV)" position="top-right" />}>
|
|
842
874
|
<div class="absolute right-10 top-2 z-10">
|
|
843
875
|
<button
|
|
876
|
+
ref={exportTriggerRef}
|
|
844
877
|
onClick={() => setShowExportMenu(!showExportMenu())}
|
|
845
878
|
class="opacity-60 hover:opacity-100 px-2 py-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-all shadow-sm"
|
|
846
879
|
title="Export table"
|
|
847
880
|
aria-label="Export table"
|
|
881
|
+
aria-haspopup="menu"
|
|
882
|
+
aria-expanded={showExportMenu()}
|
|
848
883
|
>
|
|
849
884
|
<svg class="w-3 h-3 text-gray-500 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
850
885
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
851
886
|
</svg>
|
|
852
887
|
</button>
|
|
853
|
-
<
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
</
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
</
|
|
864
|
-
</
|
|
865
|
-
|
|
888
|
+
<PortalDropdownMenu
|
|
889
|
+
open={showExportMenu()}
|
|
890
|
+
onClose={() => setShowExportMenu(false)}
|
|
891
|
+
trigger={exportTriggerRef}
|
|
892
|
+
width={144}
|
|
893
|
+
>
|
|
894
|
+
<Show when={(exportFormats as string[]).includes('tsv')}>
|
|
895
|
+
<button onClick={() => handleExport('tsv')} class="w-full text-left px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">Copy TSV</button>
|
|
896
|
+
</Show>
|
|
897
|
+
<Show when={(exportFormats as string[]).includes('csv')}>
|
|
898
|
+
<button onClick={() => handleExport('csv')} class="w-full text-left px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">Download CSV</button>
|
|
899
|
+
</Show>
|
|
900
|
+
<Show when={(exportFormats as string[]).includes('json')}>
|
|
901
|
+
<button onClick={() => handleExport('json')} class="w-full text-left px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">Download JSON</button>
|
|
902
|
+
</Show>
|
|
903
|
+
</PortalDropdownMenu>
|
|
866
904
|
</div>
|
|
867
905
|
</Show>
|
|
868
906
|
<div class={`p-4 ${isExpanded() ? 'flex-1 min-h-0 flex flex-col' : ''}`}>
|
|
@@ -1757,6 +1795,30 @@ export const UIResourceRenderer: Component<UIResourceRendererProps> = (props) =>
|
|
|
1757
1795
|
|
|
1758
1796
|
const layoutData = layout()
|
|
1759
1797
|
|
|
1798
|
+
// ── Identity + duplicate-mount detection (v6.5.0) ─────────────
|
|
1799
|
+
// `isLayoutContent` distinguishes a real composite/layout payload from
|
|
1800
|
+
// the synthetic single-component wrapping above. Drives whether the
|
|
1801
|
+
// outer wrapper carries `data-mcp-ui-layout-id` or `data-mcp-ui-component-id`.
|
|
1802
|
+
const isLayoutContent =
|
|
1803
|
+
!('type' in props.content) || (props.content as { type?: string }).type === 'composite'
|
|
1804
|
+
const outerKey = createMemo(() => getUiResourceStableKey(props.content))
|
|
1805
|
+
|
|
1806
|
+
onMount(() => {
|
|
1807
|
+
const key = outerKey()
|
|
1808
|
+
const info = _registerMount(key)
|
|
1809
|
+
if (info.count > 1) {
|
|
1810
|
+
props.onMountDuplicate?.(info)
|
|
1811
|
+
getDuplicateMountReporter()?.(info)
|
|
1812
|
+
if (isDebugEnabled() || props.debugDuplicateMounts) {
|
|
1813
|
+
// eslint-disable-next-line no-console
|
|
1814
|
+
console.warn('[mcp-ui] duplicate UIResourceRenderer mount', info)
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
})
|
|
1818
|
+
onCleanup(() => {
|
|
1819
|
+
_unregisterMount(outerKey())
|
|
1820
|
+
})
|
|
1821
|
+
|
|
1760
1822
|
// Wrapper function for RenderContext (breaks circular dependency)
|
|
1761
1823
|
const renderComponent = (component: UIComponent, onError?: (error: RendererError) => void) => (
|
|
1762
1824
|
<ComponentRenderer component={component} onError={onError} errorMode={props.errorMode} toolbarVariant={props.toolbarVariant} />
|
|
@@ -1764,11 +1826,19 @@ export const UIResourceRenderer: Component<UIResourceRendererProps> = (props) =>
|
|
|
1764
1826
|
|
|
1765
1827
|
return (
|
|
1766
1828
|
<RenderProvider renderComponent={renderComponent}>
|
|
1767
|
-
<div
|
|
1829
|
+
<div
|
|
1830
|
+
class={`w-full ${props.class || ''}`}
|
|
1831
|
+
{...(isLayoutContent
|
|
1832
|
+
? { 'data-mcp-ui-layout-id': outerKey() }
|
|
1833
|
+
: { 'data-mcp-ui-component-id': outerKey() })}
|
|
1834
|
+
>
|
|
1768
1835
|
<div class="grid gap-4" style={gridContainerStyle()}>
|
|
1769
1836
|
<For each={layoutData.components}>
|
|
1770
1837
|
{(component) => (
|
|
1771
|
-
<div
|
|
1838
|
+
<div
|
|
1839
|
+
style={getGridStyleString(component)}
|
|
1840
|
+
data-mcp-ui-component-id={getUiResourceStableKey(component)}
|
|
1841
|
+
>
|
|
1772
1842
|
<ComponentRenderer component={component} onError={props.onError} errorMode={props.errorMode} toolbarVariant={props.toolbarVariant} />
|
|
1773
1843
|
</div>
|
|
1774
1844
|
)}
|
package/src/components/index.ts
CHANGED
|
@@ -92,5 +92,9 @@ export type { RenderContextValue, RenderComponentFn } from './RenderContext'
|
|
|
92
92
|
export { ElicitationForm } from './ElicitationForm'
|
|
93
93
|
export type { ElicitationFormProps } from './ElicitationForm'
|
|
94
94
|
|
|
95
|
+
// v6.4.0 — Portal-mounted dropdown (used by table + graph Export menus)
|
|
96
|
+
export { PortalDropdownMenu } from './PortalDropdownMenu'
|
|
97
|
+
export type { PortalDropdownMenuProps } from './PortalDropdownMenu'
|
|
98
|
+
|
|
95
99
|
// Default exports for lazy loading compatibility
|
|
96
100
|
export { UIResourceRenderer as default } from './UIResourceRenderer'
|