From 9d8b0e61c315bd5b326175f1f8b60cb30e69d8e7 Mon Sep 17 00:00:00 2001 From: Harry Date: Sat, 22 Aug 2026 10:23:13 +0700 Subject: [PATCH] fix(devtools): claim the inspect click in the capture phase The source inspector listened for clicks in the bubble phase, so by the time it ran the page had already acted on the click: React had dispatched its synthetic onClick, a router link had navigated. preventDefault() cancels only the browser's own default action, so none of that could be undone. Worse, any ancestor calling stopPropagation() -- a modal or dropdown that closes on an outside click -- stopped the event before it reached document, so inspecting inside one silently did nothing at all. Claiming the click in the capture phase fixes both. It 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. --- .changeset/inspect-click-capture-phase.md | 5 ++ .../src/components/source-inspector.test.tsx | 88 ++++++++++++++++--- .../src/components/source-inspector.tsx | 17 +++- 3 files changed, 96 insertions(+), 14 deletions(-) create mode 100644 .changeset/inspect-click-capture-phase.md 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) {