From f211e18bf2473cc182bd6e1581dd5876bceeab47 Mon Sep 17 00:00:00 2001 From: Harry Date: Sat, 22 Aug 2026 10:40:21 +0700 Subject: [PATCH] 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. --- .changeset/configurable-open-source-url.md | 5 + docs/source-inspector.md | 31 +++++ .../src/components/source-inspector.test.tsx | 117 ++++++++++++++++++ .../src/components/source-inspector.tsx | 23 ++-- .../devtools/src/context/devtools-store.ts | 27 ++++ 5 files changed, 196 insertions(+), 7 deletions(-) create mode 100644 .changeset/configurable-open-source-url.md create mode 100644 packages/devtools/src/components/source-inspector.test.tsx diff --git a/.changeset/configurable-open-source-url.md b/.changeset/configurable-open-source-url.md new file mode 100644 index 000000000..94e2ffaa9 --- /dev/null +++ b/.changeset/configurable-open-source-url.md @@ -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. diff --git a/docs/source-inspector.md b/docs/source-inspector.md index aad7109a4..3d862526c 100644 --- a/docs/source-inspector.md +++ b/docs/source-inspector.md @@ -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 @@ -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=`, 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 + + `/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)). 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..b110c5a13 --- /dev/null +++ b/packages/devtools/src/components/source-inspector.test.tsx @@ -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) => + render(() => ( + + + + )) + +/** + * 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() + }) +}) diff --git a/packages/devtools/src/components/source-inspector.tsx b/packages/devtools/src/components/source-inspector.tsx index 589184cde..44012dbc2 100644 --- a/packages/devtools/src/components/source-inspector.tsx +++ b/packages/devtools/src/components/source-inspector.tsx @@ -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 @@ -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(() => { diff --git a/packages/devtools/src/context/devtools-store.ts b/packages/devtools/src/context/devtools-store.ts index df8c7a67c..956ef2b95 100644 --- a/packages/devtools/src/context/devtools-store.ts +++ b/packages/devtools/src/context/devtools-store.ts @@ -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) */ @@ -152,6 +178,7 @@ export const initialState: DevtoolsStore = { ? 'dark' : 'light', sourceAction: 'ide-warp', + openSourceUrl: undefined, triggerHidden: false, customTrigger: undefined, },