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 index b110c5a13..3b6337cec 100644 --- a/packages/devtools/src/components/source-inspector.test.tsx +++ b/packages/devtools/src/components/source-inspector.test.tsx @@ -5,6 +5,7 @@ import { SourceInspector } from './source-inspector' import type { TanStackDevtoolsConfig } from '../context/devtools-context' const SOURCE = 'src/App.tsx:12:3' +const INSPECT_KEYS = ['Shift', 'Alt', 'Control'] const renderInspector = (config?: Partial) => render(() => ( @@ -13,27 +14,39 @@ const renderInspector = (config?: Partial) => )) +/** 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 }), + ) +} + +const holdInspectHotkey = () => { + for (const key of INSPECT_KEYS) { + window.dispatchEvent(new KeyboardEvent('keydown', { key })) + } +} + /** - * Puts the pointer over a `data-tsd-source` element, arms the inspector and - * clicks. + * Puts the pointer over `element` and arms the inspector. * * The highlight effect reads the element under the cursor rather than the event - * target, so `elementFromPoint` is stubbed and the pointer moved before the - * hotkey flips the inspector on. jsdom implements no `elementFromPoint`, hence - * the assignment rather than a spy. + * 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() +} + +/** Arms the inspector over a `data-tsd-source` element and clicks it. */ const inspectClick = async () => { const target = document.createElement('button') target.setAttribute('data-tsd-source', SOURCE) document.body.append(target) - document.elementFromPoint = () => target - document.dispatchEvent( - new MouseEvent('mousemove', { clientX: 5, clientY: 5 }), - ) - for (const key of ['Shift', 'Alt', 'Control']) { - window.dispatchEvent(new KeyboardEvent('keydown', { key })) - } + hoverWithHotkey(target) await Promise.resolve() target.dispatchEvent(new MouseEvent('click', { bubbles: true })) @@ -114,4 +127,55 @@ describe('SourceInspector', () => { expect(openSourceUrl).not.toHaveBeenCalled() expect(fetch).not.toHaveBeenCalled() }) + + it('opens the source of an element whose ancestor stops click propagation', async () => { + renderInspector() + + // 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 () => { + renderInspector() + + 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 44012dbc2..9f35ef3ba 100644 --- a/packages/devtools/src/components/source-inspector.tsx +++ b/packages/devtools/src/components/source-inspector.tsx @@ -107,7 +107,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 @@ -126,7 +137,9 @@ export const SourceInspector = () => { } fetch(openSourceUrl(source)).catch(() => {}) - }) + } + + createEventListener(document, 'click', onInspectClick, { capture: true }) const currentElementBoxStyles = createMemo(() => { if (highlightState.element) {