From 153a720f4385a8c6b3243255bf7b474d81a4ad09 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 | 118 ++++++++++++++++++ .../src/components/source-inspector.tsx | 17 ++- 3 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 .changeset/inspect-click-capture-phase.md create mode 100644 packages/devtools/src/components/source-inspector.test.tsx 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) {