diff --git a/packages/devtools-kit/__tests__/component/highlighter.test.ts b/packages/devtools-kit/__tests__/component/highlighter.test.ts new file mode 100644 index 000000000..7b668eb44 --- /dev/null +++ b/packages/devtools-kit/__tests__/component/highlighter.test.ts @@ -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() + }) +}) diff --git a/packages/devtools-kit/__tests__/component/utils.test.ts b/packages/devtools-kit/__tests__/component/utils.test.ts new file mode 100644 index 000000000..bd98c5bab --- /dev/null +++ b/packages/devtools-kit/__tests__/component/utils.test.ts @@ -0,0 +1,103 @@ +import { getComponentName, getInstanceName } from '../../src/core/component/utils' + +// Minimal VueAppInstance['type'] shape used in tests +function makeType(overrides: Record = {}) { + 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') + }) +}) diff --git a/packages/devtools-kit/src/core/component-highlighter/index.ts b/packages/devtools-kit/src/core/component-highlighter/index.ts index 4c027611f..e4e15f2c2 100644 --- a/packages/devtools-kit/src/core/component-highlighter/index.ts +++ b/packages/devtools-kit/src/core/component-highlighter/index.ts @@ -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 }) } } } diff --git a/packages/devtools-kit/src/core/component/utils/index.ts b/packages/devtools-kit/src/core/component/utils/index.ts index 0a1d4dc49..62f09c408 100644 --- a/packages/devtools-kit/src/core/component/utils/index.ts +++ b/packages/devtools-kit/src/core/component/utils/index.ts @@ -6,7 +6,8 @@ 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 @@ -14,8 +15,14 @@ function getComponentTypeName(options: VueAppInstance['type']) { 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']) {