Skip to content

Commit f211e18

Browse files
committed
feat(devtools): let the source inspector's open-source URL be configured
"ide-warp" requests __tsd/open-source, which only @tanstack/devtools-vite serves. Anything else that injects data-tsd-source -- an SWC plugin under Next.js, for instance -- drives the overlay fine but has no endpoint to answer the click, and fetch(...).catch(() => {}) hides the 404, so the click looks like it did nothing. openSourceUrl takes the clicked element's data-tsd-source value and returns the URL to request. The whole URL rather than just its base, because another host generally wants another parameter shape: Next's own editor endpoint takes the position split into file, line1 and column1. A function rather than a string: settings are persisted to local storage and take priority over config on the next load, so a string would keep serving whatever the app was configured with the first time it ran. JSON.stringify drops functions, which keeps the key out of storage the way customTrigger already is.
1 parent 566d39b commit f211e18

5 files changed

Lines changed: 196 additions & 7 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+
Add `openSourceUrl` to the devtools config so the source inspector's click can reach an editor endpoint other than the one `@tanstack/devtools-vite` serves.

docs/source-inspector.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ Two things are needed for the source inspector to work:
1212
- The `@tanstack/devtools-vite` plugin must be installed and running (dev server only)
1313
- Source injection must be enabled: `injectSource.enabled: true` (this is the default)
1414

15+
Outside Vite, anything that injects the same `data-tsd-source` attribute drives the overlay just as well; see [Opening the File Somewhere Other Than Vite](#opening-the-file-somewhere-other-than-vite) for the click.
16+
1517
The feature only works in development. In production builds, source attributes are not injected.
1618

1719
## How It Works
@@ -86,6 +88,35 @@ By default, clicking an inspected element opens the file in your editor. You can
8688

8789
This is useful in environments where the Vite dev server cannot reach your editor, or when you want to paste the path elsewhere.
8890

91+
## Opening the File Somewhere Other Than Vite
92+
93+
`"ide-warp"` requests `__tsd/open-source?source=<path:line:column>`, which the Vite plugin serves. If something else injects `data-tsd-source` — an SWC plugin under Next.js, for example — that endpoint does not exist, and the click appears to do nothing: the request 404s and the failure is swallowed.
94+
95+
`openSourceUrl` replaces the whole URL, so the click can reach whatever endpoint your host does have. It receives the clicked element's `data-tsd-source` value and returns an absolute URL or a path:
96+
97+
```ts
98+
<TanStackDevtools
99+
config={{
100+
openSourceUrl: (source) =>
101+
`/api/open-editor?at=${encodeURIComponent(source)}`,
102+
}}
103+
/>
104+
```
105+
106+
The whole URL, not just its base, because a different host usually wants a different parameter shape. Next.js already serves its own editor endpoint, which takes the position split into three:
107+
108+
```ts
109+
openSourceUrl: (source) => {
110+
const [, file, line, column] = /^(.*):(\d+):(\d+)$/.exec(source) ?? []
111+
const params = new URLSearchParams(
112+
file ? { file, line1: line, column1: column } : { file: source },
113+
)
114+
return `/__nextjs_launch-editor?${params}`
115+
}
116+
```
117+
118+
Leave it unset and the Vite endpoint is used, honouring `BASE_URL` as before. It is ignored under `sourceAction: "copy-path"`, which never makes a request.
119+
89120
## Editor Configuration
90121

91122
Most popular editors work out of the box via the `launch-editor` package. Supported editors include VS Code, WebStorm, Sublime Text, Atom, and more ([full list](https://github.com/yyx990803/launch-editor?tab=readme-ov-file#supported-editors)).
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { render } from '@solidjs/testing-library'
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3+
import { DevtoolsProvider } from '../context/devtools-context'
4+
import { SourceInspector } from './source-inspector'
5+
import type { TanStackDevtoolsConfig } from '../context/devtools-context'
6+
7+
const SOURCE = 'src/App.tsx:12:3'
8+
9+
const renderInspector = (config?: Partial<TanStackDevtoolsConfig>) =>
10+
render(() => (
11+
<DevtoolsProvider config={config as TanStackDevtoolsConfig}>
12+
<SourceInspector />
13+
</DevtoolsProvider>
14+
))
15+
16+
/**
17+
* Puts the pointer over a `data-tsd-source` element, arms the inspector and
18+
* clicks.
19+
*
20+
* 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.
24+
*/
25+
const inspectClick = async () => {
26+
const target = document.createElement('button')
27+
target.setAttribute('data-tsd-source', SOURCE)
28+
document.body.append(target)
29+
document.elementFromPoint = () => target
30+
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+
}
37+
await Promise.resolve()
38+
39+
target.dispatchEvent(new MouseEvent('click', { bubbles: true }))
40+
target.remove()
41+
}
42+
43+
describe('SourceInspector', () => {
44+
beforeEach(() => {
45+
localStorage.clear()
46+
// `createElementSize` observes the name tag, and jsdom ships no
47+
// ResizeObserver.
48+
vi.stubGlobal(
49+
'ResizeObserver',
50+
class {
51+
observe() {}
52+
unobserve() {}
53+
disconnect() {}
54+
},
55+
)
56+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response()))
57+
})
58+
59+
afterEach(() => {
60+
// The held-keys list is a singleton root shared by every test in the file,
61+
// so a test that leaves the hotkey down arms the next one.
62+
window.dispatchEvent(new Event('blur'))
63+
Reflect.deleteProperty(document, 'elementFromPoint')
64+
vi.restoreAllMocks()
65+
vi.unstubAllGlobals()
66+
})
67+
68+
it('requests the devtools-vite endpoint by default', async () => {
69+
renderInspector()
70+
71+
await inspectClick()
72+
73+
expect(fetch).toHaveBeenCalledOnce()
74+
expect(String(vi.mocked(fetch).mock.calls[0]![0])).toBe(
75+
`${location.origin}/__tsd/open-source?source=${encodeURIComponent(SOURCE)}`,
76+
)
77+
})
78+
79+
it('requests the URL that openSourceUrl builds instead', async () => {
80+
const openSourceUrl = vi.fn(
81+
(source: string) => `/api/open-editor?at=${encodeURIComponent(source)}`,
82+
)
83+
renderInspector({ openSourceUrl })
84+
85+
await inspectClick()
86+
87+
expect(openSourceUrl).toHaveBeenCalledWith(SOURCE)
88+
expect(String(vi.mocked(fetch).mock.calls[0]![0])).toBe(
89+
`${location.origin}/api/open-editor?at=${encodeURIComponent(SOURCE)}`,
90+
)
91+
})
92+
93+
it('keeps an absolute URL returned by openSourceUrl on its own origin', async () => {
94+
renderInspector({
95+
openSourceUrl: () => 'http://127.0.0.1:9000/open?file=App.tsx',
96+
})
97+
98+
await inspectClick()
99+
100+
expect(String(vi.mocked(fetch).mock.calls[0]![0])).toBe(
101+
'http://127.0.0.1:9000/open?file=App.tsx',
102+
)
103+
})
104+
105+
it('does not call openSourceUrl when the action is copy-path', async () => {
106+
const openSourceUrl = vi.fn(() => '/api/open-editor')
107+
const writeText = vi.fn().mockResolvedValue(undefined)
108+
vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } })
109+
renderInspector({ sourceAction: 'copy-path', openSourceUrl })
110+
111+
await inspectClick()
112+
113+
expect(writeText).toHaveBeenCalledWith(SOURCE)
114+
expect(openSourceUrl).not.toHaveBeenCalled()
115+
expect(fetch).not.toHaveBeenCalled()
116+
})
117+
})

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

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,21 @@ export const SourceInspector = () => {
9292
})
9393
})
9494

95+
const openSourceUrl = (source: string) => {
96+
// A host that injects `data-tsd-source` without `@tanstack/devtools-vite`
97+
// has no `__tsd/open-source` to answer, and usually its own parameter shape,
98+
// so the whole URL is replaceable rather than just its base.
99+
const buildUrl = settings().openSourceUrl
100+
if (buildUrl) return new URL(buildUrl(source), location.origin)
101+
102+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
103+
const baseUrl = new URL(import.meta.env?.BASE_URL ?? '/', location.origin)
104+
return new URL(
105+
`__tsd/open-source?source=${encodeURIComponent(source)}`,
106+
baseUrl,
107+
)
108+
}
109+
95110
createEventListener(document, 'click', (e) => {
96111
if (!highlightState.element) return
97112

@@ -110,13 +125,7 @@ export const SourceInspector = () => {
110125
return
111126
}
112127

113-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
114-
const baseUrl = new URL(import.meta.env?.BASE_URL ?? '/', location.origin)
115-
const url = new URL(
116-
`__tsd/open-source?source=${encodeURIComponent(source)}`,
117-
baseUrl,
118-
)
119-
fetch(url).catch(() => {})
128+
fetch(openSourceUrl(source)).catch(() => {})
120129
})
121130

122131
const currentElementBoxStyles = createMemo(() => {

packages/devtools/src/context/devtools-store.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,32 @@ export type DevtoolsStore = {
100100
* @default "ide-warp"
101101
*/
102102
sourceAction: 'ide-warp' | 'copy-path'
103+
/**
104+
* Builds the URL that `sourceAction: "ide-warp"` requests, from the clicked
105+
* element's `data-tsd-source` value. Return an absolute URL or a path; a path
106+
* is resolved against the current origin.
107+
*
108+
* Only needed off Vite. The default targets `__tsd/open-source`, which
109+
* `@tanstack/devtools-vite` serves — a host that injects `data-tsd-source`
110+
* some other way (an SWC plugin under Next.js, say) has its own endpoint and
111+
* usually its own parameter shape, so replacing the whole URL is what makes
112+
* the feature reachable there at all.
113+
*
114+
* A function rather than a string on purpose: settings are persisted to local
115+
* storage and take priority over this config on the next load, so a string
116+
* would keep serving whatever the app was configured with the first time it
117+
* ran. `JSON.stringify` drops functions, which keeps this key out of storage
118+
* the same way `customTrigger` stays out.
119+
*
120+
* @default undefined
121+
*
122+
* Example:
123+
* ```ts
124+
* openSourceUrl: (source) =>
125+
* `/api/open-editor?at=${encodeURIComponent(source)}`
126+
* ```
127+
*/
128+
openSourceUrl?: (source: string) => string | URL
103129
/**
104130
* Whether the trigger should be completely hidden or not (you can still open with the hotkey)
105131
*/
@@ -152,6 +178,7 @@ export const initialState: DevtoolsStore = {
152178
? 'dark'
153179
: 'light',
154180
sourceAction: 'ide-warp',
181+
openSourceUrl: undefined,
155182
triggerHidden: false,
156183
customTrigger: undefined,
157184
},

0 commit comments

Comments
 (0)