Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/configurable-open-source-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/devtools': patch
---

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.
31 changes: 31 additions & 0 deletions docs/source-inspector.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ Two things are needed for the source inspector to work:
- The `@tanstack/devtools-vite` plugin must be installed and running (dev server only)
- Source injection must be enabled: `injectSource.enabled: true` (this is the default)

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.

The feature only works in development. In production builds, source attributes are not injected.

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

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

## Opening the File Somewhere Other Than Vite

`"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.

`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:

```ts
<TanStackDevtools
config={{
openSourceUrl: (source) =>
`/api/open-editor?at=${encodeURIComponent(source)}`,
}}
/>
```

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:

```ts
openSourceUrl: (source) => {
const [, file, line, column] = /^(.*):(\d+):(\d+)$/.exec(source) ?? []
const params = new URLSearchParams(
file ? { file, line1: line, column1: column } : { file: source },
)
return `/__nextjs_launch-editor?${params}`
}
```

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.

## Editor Configuration

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)).
Expand Down
117 changes: 117 additions & 0 deletions packages/devtools/src/components/source-inspector.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
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'
import type { TanStackDevtoolsConfig } from '../context/devtools-context'

const SOURCE = 'src/App.tsx:12:3'

const renderInspector = (config?: Partial<TanStackDevtoolsConfig>) =>
render(() => (
<DevtoolsProvider config={config as TanStackDevtoolsConfig}>
<SourceInspector />
</DevtoolsProvider>
))

/**
* Puts the pointer over a `data-tsd-source` element, arms the inspector and
* clicks.
*
* 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.
*/
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 }))
}
await Promise.resolve()

target.dispatchEvent(new MouseEvent('click', { bubbles: true }))
target.remove()
}

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('requests the devtools-vite endpoint by default', async () => {
renderInspector()

await inspectClick()

expect(fetch).toHaveBeenCalledOnce()
expect(String(vi.mocked(fetch).mock.calls[0]![0])).toBe(
`${location.origin}/__tsd/open-source?source=${encodeURIComponent(SOURCE)}`,
)
})

it('requests the URL that openSourceUrl builds instead', async () => {
const openSourceUrl = vi.fn(
(source: string) => `/api/open-editor?at=${encodeURIComponent(source)}`,
)
renderInspector({ openSourceUrl })

await inspectClick()

expect(openSourceUrl).toHaveBeenCalledWith(SOURCE)
expect(String(vi.mocked(fetch).mock.calls[0]![0])).toBe(
`${location.origin}/api/open-editor?at=${encodeURIComponent(SOURCE)}`,
)
})

it('keeps an absolute URL returned by openSourceUrl on its own origin', async () => {
renderInspector({
openSourceUrl: () => 'http://127.0.0.1:9000/open?file=App.tsx',
})

await inspectClick()

expect(String(vi.mocked(fetch).mock.calls[0]![0])).toBe(
'http://127.0.0.1:9000/open?file=App.tsx',
)
})

it('does not call openSourceUrl when the action is copy-path', async () => {
const openSourceUrl = vi.fn(() => '/api/open-editor')
const writeText = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } })
renderInspector({ sourceAction: 'copy-path', openSourceUrl })

await inspectClick()

expect(writeText).toHaveBeenCalledWith(SOURCE)
expect(openSourceUrl).not.toHaveBeenCalled()
expect(fetch).not.toHaveBeenCalled()
})
})
23 changes: 16 additions & 7 deletions packages/devtools/src/components/source-inspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,21 @@ export const SourceInspector = () => {
})
})

const openSourceUrl = (source: string) => {
// A host that injects `data-tsd-source` without `@tanstack/devtools-vite`
// has no `__tsd/open-source` to answer, and usually its own parameter shape,
// so the whole URL is replaceable rather than just its base.
const buildUrl = settings().openSourceUrl
if (buildUrl) return new URL(buildUrl(source), location.origin)

// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const baseUrl = new URL(import.meta.env?.BASE_URL ?? '/', location.origin)
return new URL(
`__tsd/open-source?source=${encodeURIComponent(source)}`,
baseUrl,
)
}

createEventListener(document, 'click', (e) => {
if (!highlightState.element) return

Expand All @@ -110,13 +125,7 @@ export const SourceInspector = () => {
return
}

// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const baseUrl = new URL(import.meta.env?.BASE_URL ?? '/', location.origin)
const url = new URL(
`__tsd/open-source?source=${encodeURIComponent(source)}`,
baseUrl,
)
fetch(url).catch(() => {})
fetch(openSourceUrl(source)).catch(() => {})
})

const currentElementBoxStyles = createMemo(() => {
Expand Down
27 changes: 27 additions & 0 deletions packages/devtools/src/context/devtools-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,32 @@ export type DevtoolsStore = {
* @default "ide-warp"
*/
sourceAction: 'ide-warp' | 'copy-path'
/**
* Builds the URL that `sourceAction: "ide-warp"` requests, from the clicked
* element's `data-tsd-source` value. Return an absolute URL or a path; a path
* is resolved against the current origin.
*
* Only needed off Vite. The default targets `__tsd/open-source`, which
* `@tanstack/devtools-vite` serves β€” a host that injects `data-tsd-source`
* some other way (an SWC plugin under Next.js, say) has its own endpoint and
* usually its own parameter shape, so replacing the whole URL is what makes
* the feature reachable there at all.
*
* A function rather than a string on purpose: settings are persisted to local
* storage and take priority over this 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 this key out of storage
* the same way `customTrigger` stays out.
*
* @default undefined
*
* Example:
* ```ts
* openSourceUrl: (source) =>
* `/api/open-editor?at=${encodeURIComponent(source)}`
* ```
*/
openSourceUrl?: (source: string) => string | URL
/**
* Whether the trigger should be completely hidden or not (you can still open with the hotkey)
*/
Expand Down Expand Up @@ -152,6 +178,7 @@ export const initialState: DevtoolsStore = {
? 'dark'
: 'light',
sourceAction: 'ide-warp',
openSourceUrl: undefined,
triggerHidden: false,
customTrigger: undefined,
},
Expand Down