diff --git a/.changeset/inspect-click-capture-phase.md b/.changeset/inspect-click-capture-phase.md
new file mode 100644
index 000000000..e0e50ca7b
--- /dev/null
+++ b/.changeset/inspect-click-capture-phase.md
@@ -0,0 +1,5 @@
+---
+'@tanstack/devtools': patch
+---
+
+Claim the source inspector's click in the capture phase so inspecting an element no longer also activates it, and still works inside a modal or dropdown that stops click propagation.
diff --git a/packages/devtools/src/components/source-inspector.test.tsx b/packages/devtools/src/components/source-inspector.test.tsx
new file mode 100644
index 000000000..3bfaae31c
--- /dev/null
+++ b/packages/devtools/src/components/source-inspector.test.tsx
@@ -0,0 +1,118 @@
+import { render } from '@solidjs/testing-library'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { DevtoolsProvider } from '../context/devtools-context'
+import { SourceInspector } from './source-inspector'
+
+const SOURCE = 'src/App.tsx:12:3'
+const INSPECT_KEYS = ['Shift', 'Alt', 'Control']
+
+const holdInspectHotkey = () => {
+ for (const key of INSPECT_KEYS) {
+ window.dispatchEvent(new KeyboardEvent('keydown', { key }))
+ }
+}
+
+/**
+ * Puts the pointer over `element` and arms the inspector.
+ *
+ * The highlight effect reads the element under the cursor rather than the event
+ * target, so the position has to be moved and `elementFromPoint` stubbed before
+ * the hotkey flips the inspector on.
+ */
+const hoverWithHotkey = (element: Element) => {
+ hover(element)
+ holdInspectHotkey()
+}
+
+/** jsdom implements no `elementFromPoint`, so it is assigned rather than spied on. */
+const hover = (element: Element) => {
+ document.elementFromPoint = () => element
+ document.dispatchEvent(
+ new MouseEvent('mousemove', { clientX: 5, clientY: 5 }),
+ )
+}
+
+describe('SourceInspector', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ // `createElementSize` observes the name tag, and jsdom ships no
+ // ResizeObserver.
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ },
+ )
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response()))
+ })
+
+ afterEach(() => {
+ // The held-keys list is a singleton root shared by every test in the file,
+ // so a test that leaves the hotkey down arms the next one.
+ window.dispatchEvent(new Event('blur'))
+ Reflect.deleteProperty(document, 'elementFromPoint')
+ vi.restoreAllMocks()
+ vi.unstubAllGlobals()
+ })
+
+ it('opens the source of an element whose ancestor stops click propagation', async () => {
+ render(() => (
+
+
+
+ ))
+
+ // A modal, a dropdown, a menu: anything that closes on an outside click
+ // stops propagation, which is enough to hide the click from a listener that
+ // waits for the bubble phase.
+ const modal = document.createElement('div')
+ const target = document.createElement('button')
+ target.setAttribute('data-tsd-source', SOURCE)
+ modal.append(target)
+ document.body.append(modal)
+ modal.addEventListener('click', (e) => e.stopPropagation())
+
+ const activated = vi.fn()
+ target.addEventListener('click', activated)
+
+ hoverWithHotkey(target)
+ await Promise.resolve()
+
+ target.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+
+ expect(activated).not.toHaveBeenCalled()
+ expect(fetch).toHaveBeenCalledOnce()
+ expect(String(vi.mocked(fetch).mock.calls[0]![0])).toContain(
+ `__tsd/open-source?source=${encodeURIComponent(SOURCE)}`,
+ )
+
+ modal.remove()
+ })
+
+ it('leaves ordinary clicks alone when the hotkey is not held', async () => {
+ render(() => (
+
+
+
+ ))
+
+ const target = document.createElement('button')
+ target.setAttribute('data-tsd-source', SOURCE)
+ document.body.append(target)
+
+ const activated = vi.fn()
+ target.addEventListener('click', activated)
+
+ hover(target)
+ await Promise.resolve()
+
+ target.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+
+ expect(activated).toHaveBeenCalledOnce()
+ expect(fetch).not.toHaveBeenCalled()
+
+ target.remove()
+ })
+})
diff --git a/packages/devtools/src/components/source-inspector.tsx b/packages/devtools/src/components/source-inspector.tsx
index 589184cde..1c8e24845 100644
--- a/packages/devtools/src/components/source-inspector.tsx
+++ b/packages/devtools/src/components/source-inspector.tsx
@@ -92,7 +92,18 @@ export const SourceInspector = () => {
})
})
- createEventListener(document, 'click', (e) => {
+ // Capture phase: an inspect click must not also activate what it landed on.
+ // In the bubble phase this runs after the framework has already dispatched
+ // its own click -- React's synthetic `onClick` has fired, a router link has
+ // navigated -- and `preventDefault()` cannot undo any of that; it only
+ // cancels the browser's own default action. Worse, an ancestor that calls
+ // `stopPropagation()` (every modal and dropdown that closes on an outside
+ // click) means this handler never runs at all, so inspecting inside one
+ // silently does nothing. Claiming the event first costs the page nothing:
+ // the handler returns immediately unless the inspect hotkey is held over an
+ // element carrying `data-tsd-source`, and it already performs the
+ // open-in-editor or copy itself rather than relying on anything downstream.
+ const onInspectClick = (e: MouseEvent) => {
if (!highlightState.element) return
// Snapshot the source before any signal writes: setDisabledAfterClick
@@ -117,7 +128,9 @@ export const SourceInspector = () => {
baseUrl,
)
fetch(url).catch(() => {})
- })
+ }
+
+ createEventListener(document, 'click', onInspectClick, { capture: true })
const currentElementBoxStyles = createMemo(() => {
if (highlightState.element) {