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
103 changes: 103 additions & 0 deletions packages/devtools-kit/__tests__/component/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { getComponentName, getInstanceName } from '../../src/core/component/utils'

// Minimal VueAppInstance['type'] shape used in tests
function makeType(overrides: Record<string, unknown> = {}) {
return overrides as any
}

describe('getComponentName', () => {
it('returns displayName when present', () => {
expect(getComponentName(makeType({ displayName: 'MyDisplay' }))).toBe('MyDisplay')
})

it('returns name when present', () => {
expect(getComponentName(makeType({ name: 'MyComp' }))).toBe('MyComp')
})

it('derives name from .vue __file', () => {
expect(getComponentName(makeType({ __file: '/src/components/MyButton.vue' }))).toBe('MyButton')
})

it('derives name from .jsx __file', () => {
expect(getComponentName(makeType({ __file: '/src/components/MyButton.jsx' }))).toBe('MyButton')
})

it('derives name from .tsx __file', () => {
expect(getComponentName(makeType({ __file: '/src/components/MyButton.tsx' }))).toBe('MyButton')
})

it('derives PascalCase name from kebab-case jsx file', () => {
expect(getComponentName(makeType({ __file: '/src/my-button.jsx' }))).toBe('MyButton')
})

it('returns undefined when no identifying info exists', () => {
expect(getComponentName(makeType({}))).toBeUndefined()
})
})

describe('getInstanceName', () => {
it('returns component name for SFC', () => {
const instance = { type: { name: 'HelloWorld' } } as any
expect(getInstanceName(instance)).toBe('HelloWorld')
})

it('returns name derived from .tsx __file when no explicit name', () => {
const instance = { type: { __file: '/src/Counter.tsx' } } as any
expect(getInstanceName(instance)).toBe('Counter')
})

it('returns name derived from .jsx __file when no explicit name', () => {
const instance = { type: { __file: '/src/Counter.jsx' } } as any
expect(getInstanceName(instance)).toBe('Counter')
})

it('suppresses "index" name for index.jsx files', () => {
// index.jsx should not surface the name "index", same as index.vue
const instance = {
type: { __name: 'index', __file: '/src/components/MyComp/index.jsx' },
root: {},
parent: null,
appContext: { components: {} },
} as any
// __name is 'index' but file ends with index.jsx → falls through to filename-based name
// getComponentTypeName returns '' → getInstanceName tries filename → returns 'MyComp'
expect(getInstanceName(instance)).toBe('MyComp')
})

it('suppresses "index" name for index.tsx files', () => {
const instance = {
type: { __name: 'index', __file: '/src/components/MyComp/index.tsx' },
root: {},
parent: null,
appContext: { components: {} },
} as any
expect(getInstanceName(instance)).toBe('MyComp')
})

it('returns functional component name from function.name', () => {
function MyFunctional() {
return null
}
const instance = { type: MyFunctional } as any
expect(getInstanceName(instance)).toBe('MyFunctional')
})

it('returns functional component displayName over function.name', () => {
function MyFunctional() {
return null
}
;(MyFunctional as any).displayName = 'BetterName'
const instance = { type: MyFunctional } as any
expect(getInstanceName(instance)).toBe('BetterName')
})

it('falls back to "Anonymous Component"', () => {
const instance = {
type: {},
root: {},
parent: null,
appContext: { components: {} },
} as any
expect(getInstanceName(instance)).toBe('Anonymous Component')
})
})
11 changes: 9 additions & 2 deletions packages/devtools-kit/src/core/component/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,23 @@ function getComponentTypeName(options: VueAppInstance['type']) {
return options.displayName || options.name || options.__VUE_DEVTOOLS_COMPONENT_GUSSED_NAME__ || ''
}
const name = options.name || options._componentTag || options.__VUE_DEVTOOLS_COMPONENT_GUSSED_NAME__ || options.__name
if (name === 'index' && options.__file?.endsWith('index.vue')) {
const file = options.__file
if (name === 'index' && file && (file.endsWith('index.vue') || file.endsWith('index.jsx') || file.endsWith('index.tsx'))) {
return ''
}
return name
}

function getComponentFileName(options: VueAppInstance['type']) {
const file = options.__file
if (file)
if (!file)
return
if (file.endsWith('.vue'))
return classify(basename(file, '.vue'))
if (file.endsWith('.jsx'))
return classify(basename(file, '.jsx'))
if (file.endsWith('.tsx'))
return classify(basename(file, '.tsx'))
}

export function getComponentName(options: VueAppInstance['type']) {
Expand Down
40 changes: 24 additions & 16 deletions packages/devtools-kit/src/core/open-in-editor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,29 @@ export function setOpenInEditorBaseUrl(url: string) {

export function openInEditor(options: OpenInEditorOptions = {}) {
const { file, host, baseUrl = window.location.origin, line = 0, column = 0 } = options
if (file) {
if (host === 'chrome-extension') {
const fileName = file.replace(/\\/g, '\\\\')
// @ts-expect-error skip type check
const _baseUrl = window.VUE_DEVTOOLS_CONFIG?.openInEditorHost ?? '/'
fetch(`${_baseUrl}__open-in-editor?file=${encodeURI(file)}`).then((response) => {
if (!response.ok) {
const msg = `Opening component ${fileName} failed`
console.log(`%c${msg}`, 'color:red')
}
})
}
else if (devtoolsState.vitePluginDetected) {
const _baseUrl = target.__VUE_DEVTOOLS_OPEN_IN_EDITOR_BASE_URL__ ?? baseUrl
target.__VUE_INSPECTOR__.openInEditor(_baseUrl, file, line, column)
}
if (!file)
return

// When the Vite plugin is active __VUE_INSPECTOR__ is the most reliable path —
// it uses the properly configured launch-editor-middleware. Prefer it even when
// the devtools UI is running inside a Chrome extension panel.
if (devtoolsState.vitePluginDetected && target.__VUE_INSPECTOR__) {
const _baseUrl = target.__VUE_DEVTOOLS_OPEN_IN_EDITOR_BASE_URL__ ?? baseUrl
target.__VUE_INSPECTOR__.openInEditor(_baseUrl, file, line, column)
return
}

// Fallback for Chrome extension without the Vite plugin: send a plain fetch
// to the /__open-in-editor endpoint and log clearly on failure.
if (host === 'chrome-extension') {
const fileName = file.replace(/\\/g, '\\\\')
// @ts-expect-error skip type check
const _baseUrl = window.VUE_DEVTOOLS_CONFIG?.openInEditorHost ?? '/'
fetch(`${_baseUrl}__open-in-editor?file=${encodeURI(file)}`).then((response) => {
if (!response.ok) {
const msg = `Opening component ${fileName} failed — is the Vite plugin (vite-plugin-vue-devtools) installed in your app?`
console.log(`%c${msg}`, 'color:red')
}
})
}
}
77 changes: 77 additions & 0 deletions packages/vite/__tests__/jsx-file-injection.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import { injectJsxFile } from '../src/jsx-file-injection'

const FILE = '/project/src/components/MyButton.tsx'

describe('injectJsxFile', () => {
it('ignores files that are not jsx/tsx', () => {
expect(injectJsxFile('export const a = 1', '/project/src/a.ts')).toBeUndefined()
expect(injectJsxFile('export const a = 1', '/project/src/App.vue')).toBeUndefined()
})

it('returns undefined when there is nothing to stamp', () => {
expect(injectJsxFile('export const answer = 42', FILE)).toBeUndefined()
})

it('stamps __file on bindings plugin-vue-jsx tagged with __hmrId', () => {
const code = [
'const __default__ = defineComponent({})',
'export default __default__',
'__default__.__hmrId = "61c61d33"',
'__VUE_HMR_RUNTIME__.createRecord("61c61d33", __default__)',
].join('\n')

const out = injectJsxFile(code, FILE)!

expect(out).toContain(`__default__.__file = "${FILE}"`)
// must land before the __hmrId assignment it piggybacks on
expect(out.indexOf('__default__.__file')).toBeLessThan(out.indexOf('__default__.__hmrId'))
})

it('stamps every tagged binding in a multi-component module', () => {
const code = [
'Alpha.__hmrId = "aaa"',
'Beta.__hmrId = "bbb"',
].join('\n')

const out = injectJsxFile(code, FILE)!

expect(out).toContain(`Alpha.__file = "${FILE}"`)
expect(out).toContain(`Beta.__file = "${FILE}"`)
})

it('stamps a plain function component named after its file', () => {
const out = injectJsxFile('export function MyButton(props) { return null }', FILE)!

expect(out).toContain(`MyButton.__file = "${FILE}"`)
// guarded so a non-component binding cannot throw under ESM strict mode
expect(out).toContain('typeof MyButton === "function"')
})

it('stamps a plain const arrow component named after its file', () => {
const out = injectJsxFile('const MyButton = () => null; export default MyButton', FILE)!
expect(out).toContain(`MyButton.__file = "${FILE}"`)
})

it('does not stamp a binding this module only imports', () => {
// MyButton.tsx re-exporting a MyButton defined elsewhere: stamping it would
// attribute the other module's component to this file.
const code = 'import MyButton from "../base/MyButton"\nexport default MyButton'
expect(injectJsxFile(code, FILE)).toBeUndefined()
})

it('does not stamp twice when __file is already present', () => {
const code = 'function MyButton() {}\nMyButton.__file = "/somewhere/else.tsx"'
expect(injectJsxFile(code, FILE)).toBeUndefined()
})

it('ignores lowercase filenames, which are not component conventions', () => {
const code = 'export function helpers() {}'
expect(injectJsxFile(code, '/project/src/helpers.tsx')).toBeUndefined()
})

it('strips the query string from the module id', () => {
const out = injectJsxFile('export function MyButton() {}', `${FILE}?v=abc123`)!
expect(out).toContain(`MyButton.__file = "${FILE}"`)
})
})
65 changes: 65 additions & 0 deletions packages/vite/src/jsx-file-injection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import path from 'node:path'
import { normalizePath } from 'vite'

/**
* `@vitejs/plugin-vue` attaches `__file` to every SFC during serve so that
* devtools can resolve a component's source path — it powers the "Open in
* Editor" button, which is gated on `instance.type.__file`.
*
* `@vitejs/plugin-vue-jsx` has no equivalent: it only emits `__hmrId`, which is
* a hash with no path in it. JSX/TSX components therefore have no `__file` and
* the button never renders for them.
*
* Until that is fixed upstream (vitejs/vite-plugin-vue#784), attach `__file`
* ourselves. Two cases are covered:
*
* 1. `defineComponent` components, which `plugin-vue-jsx` has already tagged
* with `__hmrId` — we stamp the same local binding.
* 2. Plain function/arrow components, which get no `__hmrId` at all. Those are
* matched by the usual convention of naming the component after its file.
*
* Returns `undefined` when nothing was injected, so the caller can skip the
* transform entirely.
*/
export function injectJsxFile(code: string, id: string): string | undefined {
const filename = id.split('?')[0]
if (!/\.[jt]sx$/.test(filename))
return

// Normalise before deriving the basename so separators are handled the same
// way regardless of which OS the dev server runs on.
const normalized = normalizePath(filename)
const fileJson = JSON.stringify(normalized)
let transformed = code

// Case 1: piggyback on the `__hmrId` assignments plugin-vue-jsx emits.
transformed = transformed.replace(
/\b(\w+)\.__hmrId\s*=/g,
(match, localName) => `${localName}.__file = ${fileJson}\n${match}`,
)

// Case 2: plain function/arrow components. Only stamp a binding this module
// declares itself — stamping an imported one would attribute another
// module's component to this file.
const componentName = path.posix.basename(normalized, path.posix.extname(normalized))
if (
componentName
&& /^[A-Z][\w$]*$/.test(componentName)
&& !transformed.includes(`${componentName}.__file`)
&& declaresBinding(transformed, componentName)
) {
// A local `const Foo = 'not a component'` would throw on property
// assignment under ESM strict mode, so narrow to objects and functions.
transformed
+= `\nif (typeof ${componentName} === "function" || (typeof ${componentName} === "object" && ${componentName} !== null))`
+ ` ${componentName}.__file = ${fileJson}`
}

return transformed === code ? undefined : transformed
}

function declaresBinding(code: string, name: string) {
return new RegExp(
`(?:^|[\\s;{}(,])(?:async\\s+)?(?:function|const|let|var|class)\\s+${name}\\b`,
).test(code)
}
13 changes: 13 additions & 0 deletions packages/vite/src/vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { normalizePath } from 'vite'
import Inspect from 'vite-plugin-inspect'
import VueInspector from 'vite-plugin-vue-inspector'
import { DIR_CLIENT } from './dir'
import { injectJsxFile } from './jsx-file-injection'
import { getRpcFunctions } from './rpc'
import { removeUrlQuery } from './utils'

Expand Down Expand Up @@ -215,6 +216,17 @@ export default function VitePluginVueDevTools(options?: VitePluginVueDevToolsOpt
},
}

// Attach `__file` to JSX/TSX components so devtools can offer "Open in
// Editor" for them, the way it already can for SFCs. See jsx-file-injection.ts.
const jsxFileInjection: PluginOption = {
name: 'vite-plugin-vue-devtools:jsx-file-injection',
enforce: 'post',
apply: 'serve',
transform(code, id) {
return injectJsxFile(code, id)
},
}

return [
inspect as PluginOption,
pluginOptions.componentInspector && VueInspector({
Expand All @@ -227,5 +239,6 @@ export default function VitePluginVueDevTools(options?: VitePluginVueDevToolsOpt
appendTo: pluginOptions.appendTo || 'manually',
}) as PluginOption,
plugin,
jsxFileInjection,
].filter(Boolean)
}