Skip to content

Commit dbd66b8

Browse files
committed
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.
1 parent afa01fe commit dbd66b8

3 files changed

Lines changed: 96 additions & 14 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/devtools': patch
3+
---
4+
5+
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.

‎packages/devtools/src/components/source-inspector.test.tsx‎

Lines changed: 76 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { SourceInspector } from './source-inspector'
55
import type { TanStackDevtoolsConfig } from '../context/devtools-context'
66

77
const SOURCE = 'src/App.tsx:12:3'
8+
const INSPECT_KEYS = ['Shift', 'Alt', 'Control']
89

910
const renderInspector = (config?: Partial<TanStackDevtoolsConfig>) =>
1011
render(() => (
@@ -13,27 +14,39 @@ const renderInspector = (config?: Partial<TanStackDevtoolsConfig>) =>
1314
</DevtoolsProvider>
1415
))
1516

17+
/** jsdom implements no `elementFromPoint`, so it is assigned rather than spied on. */
18+
const hover = (element: Element) => {
19+
document.elementFromPoint = () => element
20+
document.dispatchEvent(
21+
new MouseEvent('mousemove', { clientX: 5, clientY: 5 }),
22+
)
23+
}
24+
25+
const holdInspectHotkey = () => {
26+
for (const key of INSPECT_KEYS) {
27+
window.dispatchEvent(new KeyboardEvent('keydown', { key }))
28+
}
29+
}
30+
1631
/**
17-
* Puts the pointer over a `data-tsd-source` element, arms the inspector and
18-
* clicks.
32+
* Puts the pointer over `element` and arms the inspector.
1933
*
2034
* The highlight effect reads the element under the cursor rather than the event
21-
* target, so `elementFromPoint` is stubbed and the pointer moved before the
22-
* hotkey flips the inspector on. jsdom implements no `elementFromPoint`, hence
23-
* the assignment rather than a spy.
35+
* target, so the position has to be moved and `elementFromPoint` stubbed before
36+
* the hotkey flips the inspector on.
2437
*/
38+
const hoverWithHotkey = (element: Element) => {
39+
hover(element)
40+
holdInspectHotkey()
41+
}
42+
43+
/** Arms the inspector over a `data-tsd-source` element and clicks it. */
2544
const inspectClick = async () => {
2645
const target = document.createElement('button')
2746
target.setAttribute('data-tsd-source', SOURCE)
2847
document.body.append(target)
29-
document.elementFromPoint = () => target
3048

31-
document.dispatchEvent(
32-
new MouseEvent('mousemove', { clientX: 5, clientY: 5 }),
33-
)
34-
for (const key of ['Shift', 'Alt', 'Control']) {
35-
window.dispatchEvent(new KeyboardEvent('keydown', { key }))
36-
}
49+
hoverWithHotkey(target)
3750
await Promise.resolve()
3851

3952
target.dispatchEvent(new MouseEvent('click', { bubbles: true }))
@@ -114,4 +127,55 @@ describe('SourceInspector', () => {
114127
expect(openSourceUrl).not.toHaveBeenCalled()
115128
expect(fetch).not.toHaveBeenCalled()
116129
})
130+
131+
it('opens the source of an element whose ancestor stops click propagation', async () => {
132+
renderInspector()
133+
134+
// A modal, a dropdown, a menu: anything that closes on an outside click
135+
// stops propagation, which is enough to hide the click from a listener that
136+
// waits for the bubble phase.
137+
const modal = document.createElement('div')
138+
const target = document.createElement('button')
139+
target.setAttribute('data-tsd-source', SOURCE)
140+
modal.append(target)
141+
document.body.append(modal)
142+
modal.addEventListener('click', (e) => e.stopPropagation())
143+
144+
const activated = vi.fn()
145+
target.addEventListener('click', activated)
146+
147+
hoverWithHotkey(target)
148+
await Promise.resolve()
149+
150+
target.dispatchEvent(new MouseEvent('click', { bubbles: true }))
151+
152+
expect(activated).not.toHaveBeenCalled()
153+
expect(fetch).toHaveBeenCalledOnce()
154+
expect(String(vi.mocked(fetch).mock.calls[0]![0])).toContain(
155+
`__tsd/open-source?source=${encodeURIComponent(SOURCE)}`,
156+
)
157+
158+
modal.remove()
159+
})
160+
161+
it('leaves ordinary clicks alone when the hotkey is not held', async () => {
162+
renderInspector()
163+
164+
const target = document.createElement('button')
165+
target.setAttribute('data-tsd-source', SOURCE)
166+
document.body.append(target)
167+
168+
const activated = vi.fn()
169+
target.addEventListener('click', activated)
170+
171+
hover(target)
172+
await Promise.resolve()
173+
174+
target.dispatchEvent(new MouseEvent('click', { bubbles: true }))
175+
176+
expect(activated).toHaveBeenCalledOnce()
177+
expect(fetch).not.toHaveBeenCalled()
178+
179+
target.remove()
180+
})
117181
})

‎packages/devtools/src/components/source-inspector.tsx‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,18 @@ export const SourceInspector = () => {
107107
)
108108
}
109109

110-
createEventListener(document, 'click', (e) => {
110+
// Capture phase: an inspect click must not also activate what it landed on.
111+
// In the bubble phase this runs after the framework has already dispatched
112+
// its own click -- React's synthetic `onClick` has fired, a router link has
113+
// navigated -- and `preventDefault()` cannot undo any of that; it only
114+
// cancels the browser's own default action. Worse, an ancestor that calls
115+
// `stopPropagation()` (every modal and dropdown that closes on an outside
116+
// click) means this handler never runs at all, so inspecting inside one
117+
// silently does nothing. Claiming the event first costs the page nothing:
118+
// the handler returns immediately unless the inspect hotkey is held over an
119+
// element carrying `data-tsd-source`, and it already performs the
120+
// open-in-editor or copy itself rather than relying on anything downstream.
121+
const onInspectClick = (e: MouseEvent) => {
111122
if (!highlightState.element) return
112123

113124
// Snapshot the source before any signal writes: setDisabledAfterClick
@@ -126,7 +137,9 @@ export const SourceInspector = () => {
126137
}
127138

128139
fetch(openSourceUrl(source)).catch(() => {})
129-
})
140+
}
141+
142+
createEventListener(document, 'click', onInspectClick, { capture: true })
130143

131144
const currentElementBoxStyles = createMemo(() => {
132145
if (highlightState.element) {

0 commit comments

Comments
 (0)