@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
@@ -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
- <Show when={exportMenuOpen()}>
294
- <div class="absolute right-0 mt-1 w-44 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded shadow-lg text-xs">
295
- <For each={[
296
- { label: 'Download PNG', onClick: handleExportPNG, hint: 'visual snapshot' },
297
- { label: 'Download Mermaid', onClick: handleExportMermaid, hint: 'markdown / GitHub' },
298
- { label: 'Download JSON', onClick: handleExportJSON, hint: 'raw data' },
299
- ]}>
300
- {(item) => (
301
- <button
302
- type="button"
303
- onClick={item.onClick}
304
- 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"
305
- >
306
- <div class="font-medium">{item.label}</div>
307
- <div class="text-[10px] text-gray-500 dark:text-gray-400">{item.hint}</div>
308
- </button>
309
- )}
310
- </For>
311
- </div>
312
- </Show>
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
- <Show when={showExportMenu()}>
854
- <div class="absolute right-0 mt-1 w-36 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 text-sm">
855
- <Show when={(exportFormats as string[]).includes('tsv')}>
856
- <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>
857
- </Show>
858
- <Show when={(exportFormats as string[]).includes('csv')}>
859
- <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>
860
- </Show>
861
- <Show when={(exportFormats as string[]).includes('json')}>
862
- <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>
863
- </Show>
864
- </div>
865
- </Show>
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 class={`w-full ${props.class || ''}`}>
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 style={getGridStyleString(component)}>
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
  )}
@@ -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'