Skip to content
Closed
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
92 changes: 92 additions & 0 deletions packages/devtools-kit/__tests__/component/highlighter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, vi } from 'vitest'
import { cancelInspectComponentHighLighter, inspectComponentHighLighter } from '../../src/core/component-highlighter'
import * as boundingRect from '../../src/core/component/state/bounding-rect'

vi.mock('../../src/ctx', () => ({ activeAppRecord: { value: null } }))

function makeFakeInstance(el: HTMLElement) {
return {
uid: 42,
vnode: { el, key: null },
subTree: { el, type: {} },
type: { name: 'FakeComp' },
appContext: { app: { __VUE_DEVTOOLS_NEXT_APP_RECORD_ID__: 0 } },
} as any
}

const CONTAINER_ID = '__vue-devtools-component-inspector__'

beforeEach(() => {
document.body.innerHTML = ''
vi.spyOn(boundingRect, 'getComponentBoundingRect').mockReturnValue({
top: 10,
left: 10,
width: 100,
height: 50,
} as any)
})

afterEach(() => {
cancelInspectComponentHighLighter()
vi.restoreAllMocks()
})

describe('inspectFn DOM walking', () => {
it('highlights and selects when __vueParentComponent is on the exact target element', async () => {
const div = document.createElement('div')
document.body.appendChild(div)
const instance = makeFakeInstance(div)
;(div as any).__vueParentComponent = instance

const promise = inspectComponentHighLighter()

// hover → highlight overlay is created
div.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))
await Promise.resolve()
expect(document.getElementById(CONTAINER_ID)).not.toBeNull()

// click → promise resolves with the selected component id
div.dispatchEvent(new MouseEvent('click', { bubbles: true }))
const result = await promise
expect(JSON.parse(result)).toMatchObject({ id: '0:42' })
})

it('highlights when __vueParentComponent is only on a parent element (JSX case)', async () => {
// In JSX/functional components __vueParentComponent is often only on the
// root element of the component, not on every inner child.
const parent = document.createElement('div')
const child = document.createElement('span')
parent.appendChild(child)
document.body.appendChild(parent)

const instance = makeFakeInstance(parent)
;(parent as any).__vueParentComponent = instance
// child deliberately has NO __vueParentComponent set

const promise = inspectComponentHighLighter()

// hover on child — the walker should climb to parent and find the instance
child.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))
await Promise.resolve()
expect(document.getElementById(CONTAINER_ID)).not.toBeNull()

// click to resolve
child.dispatchEvent(new MouseEvent('click', { bubbles: true }))
const result = await promise
expect(JSON.parse(result)).toMatchObject({ id: '0:42' })
})

it('does not create a highlight overlay when no ancestor has __vueParentComponent', async () => {
const div = document.createElement('div')
document.body.appendChild(div)
// no __vueParentComponent anywhere in the tree

inspectComponentHighLighter()

div.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))
await Promise.resolve()

expect(document.getElementById(CONTAINER_ID)).toBeNull()
cancelInspectComponentHighLighter()
})
})
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')
})
})
26 changes: 15 additions & 11 deletions packages/devtools-kit/src/core/component-highlighter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,18 +160,22 @@ export function unhighlight() {

let inspectInstance: VueAppInstance = null!
function inspectFn(e: MouseEvent) {
const target = e.target as { __vueParentComponent?: VueAppInstance }
if (target) {
// Walk up the DOM tree to find the nearest element with a Vue component instance.
// JSX/functional components often don't set __vueParentComponent on every child element,
// so checking only e.target misses them.
let target = e.target as HTMLElement & { __vueParentComponent?: VueAppInstance }
while (target && !target.__vueParentComponent) {
target = target.parentElement as HTMLElement & { __vueParentComponent?: VueAppInstance }
}
if (target?.__vueParentComponent) {
const instance = target.__vueParentComponent
if (instance) {
inspectInstance = instance
const el = instance.vnode.el as HTMLElement | undefined
if (el) {
const bounds = getComponentBoundingRect(instance)
const name = getInstanceName(instance)
const container = getContainerElement()
container ? update({ bounds, name }) : create({ bounds, name })
}
inspectInstance = instance
const el = instance.vnode.el as HTMLElement | undefined
if (el) {
const bounds = getComponentBoundingRect(instance)
const name = getInstanceName(instance)
const container = getContainerElement()
container ? update({ bounds, name }) : create({ bounds, name })
}
}
}
Expand Down
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')
}
})
}
}
42 changes: 42 additions & 0 deletions packages/vite/src/vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,47 @@ export default function VitePluginVueDevTools(options?: VitePluginVueDevToolsOpt
},
}

// @vitejs/plugin-vue-jsx injects `__hmrId` on every component it recognises
// (defineComponent-based only) but never injects `__file`, so devtools cannot
// show the "Open in Editor" button for JSX/TSX components.
// This post-transform covers two cases:
// 1. Components already tagged with __hmrId (defineComponent pattern)
// 2. Plain function/arrow exports whose name matches the PascalCase file name
// (the common convention for React-style functional components in Vue TSX)
const jsxFileInjection: PluginOption = {
name: 'vite-plugin-vue-devtools:jsx-file-injection',
enforce: 'post',
apply: 'serve',
transform(code, id) {
const filename = id.split('?')[0]
if (!/\.[jt]sx$/.test(filename))
return

const normalizedPath = normalizePath(filename)
const fileJson = JSON.stringify(normalizedPath)
let transformed = code

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

// Case 2: plain exports — derive component name from file name (PascalCase)
// and inject __file on it if it exists and hasn't already been handled above.
const componentName = path.basename(filename, path.extname(filename))
if (
componentName
&& /^[A-Z]/.test(componentName)
&& !transformed.includes(`${componentName}.__file`)
) {
transformed += `\ntypeof ${componentName} !== "undefined" && (${componentName}.__file = ${fileJson})`
}

return transformed === code ? undefined : transformed
},
}

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