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) {