From 10d1467b4084d0a7f4808884a588b54c0bea9286 Mon Sep 17 00:00:00 2001 From: Aejkatappaja Date: Sun, 4 Oct 2026 23:22:23 +0200 Subject: [PATCH 1/2] fix(devtools): type the RPC resolver with its own this The resolver used birpc's default generics, which don't match the RPC's own, so @qwik.dev/mcp failed to typecheck. Our resolver ignores this, so BirpcResolver fits every caller. --- packages/devtools/kit/src/rpc-core.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/devtools/kit/src/rpc-core.ts b/packages/devtools/kit/src/rpc-core.ts index b3f2e18cdcf..188b8b0af19 100644 --- a/packages/devtools/kit/src/rpc-core.ts +++ b/packages/devtools/kit/src/rpc-core.ts @@ -1,10 +1,10 @@ import SuperJSON from 'superjson'; -import { createBirpc, type EventOptions } from 'birpc'; +import { createBirpc, type BirpcResolver } from 'birpc'; interface RpcChannel { post: (serialized: string) => void; on: (handler: (serialized: string) => void) => void; - resolver?: EventOptions['resolver']; + resolver?: BirpcResolver; } const RPC_TIMEOUT = 120_000; From 571796fa3fa52ff684038936e1e631a9cca8b972 Mon Sep 17 00:00:00 2001 From: Aejkatappaja Date: Sun, 4 Oct 2026 23:22:24 +0200 Subject: [PATCH 2/2] fix(devtools): keep source line numbers when instrumenting components DevTools and MCP instrument component files and root.tsx before the Qwik optimizer, which takes no input sourcemap. Every inserted line shifted the locations it computes, so data-qwik-inspector (click-to-source) pointed several lines below the element. Insert everything inline so source lines stay where they are. --- .../devtools-instrumentation-line-numbers.md | 6 + .../parse/__snapshots__/parse.unit.ts.snap | 228 ++---------------- packages/devtools/plugin/src/parse/helpers.ts | 40 +-- .../devtools/plugin/src/parse/hookTracker.ts | 40 +-- .../devtools/plugin/src/parse/initInjector.ts | 27 +-- .../devtools/plugin/src/parse/parse.unit.ts | 14 +- .../devtools/plugin/src/plugin/devtools.ts | 1 + .../plugin/src/transforms/root-transform.ts | 55 +---- .../plugin/src/transforms/source-utils.ts | 7 +- .../plugin/src/transforms/transforms.unit.ts | 49 +++- packages/mcp/src/index.ts | 1 + packages/mcp/src/integration.test.ts | 6 + 12 files changed, 128 insertions(+), 346 deletions(-) create mode 100644 .changeset/devtools-instrumentation-line-numbers.md diff --git a/.changeset/devtools-instrumentation-line-numbers.md b/.changeset/devtools-instrumentation-line-numbers.md new file mode 100644 index 00000000000..92ffca6735a --- /dev/null +++ b/.changeset/devtools-instrumentation-line-numbers.md @@ -0,0 +1,6 @@ +--- +'@qwik.dev/devtools': patch +'@qwik.dev/mcp': patch +--- + +fix: click-to-source opens the right line when devtools or mcp is enabled diff --git a/packages/devtools/plugin/src/parse/__snapshots__/parse.unit.ts.snap b/packages/devtools/plugin/src/parse/__snapshots__/parse.unit.ts.snap index 01c5846cda5..cf3de966fd3 100644 --- a/packages/devtools/plugin/src/parse/__snapshots__/parse.unit.ts.snap +++ b/packages/devtools/plugin/src/parse/__snapshots__/parse.unit.ts.snap @@ -38,8 +38,7 @@ interface ButtonProps { testValue: Signal; } -export default component$((props) => { - const collecthook = useCollectHooks("/abs/path/Button.tsx_default") +export default component$((props) => { const collecthook = useCollectHooks("/abs/path/Button.tsx_default"); const { class: className = '', onClick$ } = props; const testValue2 = props.testValue console.log('testValue', testValue2) @@ -48,97 +47,27 @@ export default component$((props) => { dd:12, cc: 33, aa: [1,2,3 ] - }); - collecthook({ - variableName: 'store', - hookType: 'useStore', - category: 'VariableDeclarator', - data: store - }); - - const signal = useSignal('111'); - collecthook({ - variableName: 'signal', - hookType: 'useSignal', - category: 'VariableDeclarator', - data: signal - }); - - const constantValue = useConstant(() => 'CONST'); - collecthook({ - variableName: 'constantValue', - hookType: 'useConstant', - category: 'VariableDeclarator', - data: constantValue - }); - - const serverData = useServerData('demo-key'); - collecthook({ - variableName: 'serverData', - hookType: 'useServerData', - category: 'VariableDeclarator', - data: serverData - }); - - const location = useLocation(); - collecthook({ - variableName: 'location', - hookType: 'useLocation', - category: 'VariableDeclarator', - data: location - }); - - const navigate = useNavigate(); - collecthook({ - variableName: 'navigate', - hookType: 'useNavigate', - category: 'VariableDeclarator', - data: navigate - }); - - const content = useContent(); - collecthook({ - variableName: 'content', - hookType: 'useContent', - category: 'VariableDeclarator', - data: content - }); - - const head = useDocumentHead(); - collecthook({ - variableName: 'head', - hookType: 'useDocumentHead', - category: 'VariableDeclarator', - data: head - }); - + }); collecthook({ variableName: 'store', hookType: 'useStore', category: 'VariableDeclarator', data: store }); + const signal = useSignal('111'); collecthook({ variableName: 'signal', hookType: 'useSignal', category: 'VariableDeclarator', data: signal }); + const constantValue = useConstant(() => 'CONST'); collecthook({ variableName: 'constantValue', hookType: 'useConstant', category: 'VariableDeclarator', data: constantValue }); + const serverData = useServerData('demo-key'); collecthook({ variableName: 'serverData', hookType: 'useServerData', category: 'VariableDeclarator', data: serverData }); + const location = useLocation(); collecthook({ variableName: 'location', hookType: 'useLocation', category: 'VariableDeclarator', data: location }); + const navigate = useNavigate(); collecthook({ variableName: 'navigate', hookType: 'useNavigate', category: 'VariableDeclarator', data: navigate }); + const content = useContent(); collecthook({ variableName: 'content', hookType: 'useContent', category: 'VariableDeclarator', data: content }); + const head = useDocumentHead(); collecthook({ variableName: 'head', hookType: 'useDocumentHead', category: 'VariableDeclarator', data: head }); useTask$(({ track }) => { track(() => store.count); signal.value = '33333' - }) - collecthook({ - variableName: 'useTask', - hookType: 'useTask', - category: 'expressionStatement', - data: undefined - }); - + }); collecthook({ variableName: 'useTask', hookType: 'useTask', category: 'expressionStatement', data: undefined }); // eslint-disable-next-line qwik/no-use-visible-task useVisibleTask$(({ track }) => { track(() => store.count); signal.value = '2227' - }) - collecthook({ - variableName: 'useVisibleTask', - hookType: 'useVisibleTask', - category: 'expressionStatement', - data: undefined - }); - + }); collecthook({ variableName: 'useVisibleTask', hookType: 'useVisibleTask', category: 'expressionStatement', data: undefined }); const qwikContainer = useComputed$(() => { try { @@ -151,102 +80,32 @@ export default component$((props) => { console.error(error); return null; } - }); - collecthook({ - variableName: 'qwikContainer', - hookType: 'useComputed', - category: 'VariableDeclarator', - data: qwikContainer - }); - + }); collecthook({ variableName: 'qwikContainer', hookType: 'useComputed', category: 'VariableDeclarator', data: qwikContainer }); const asyncComputedValue = useAsyncComputed$(({ track }) => Promise.resolve(track(signal) + 3), - ); - collecthook({ - variableName: 'asyncComputedValue', - hookType: 'useAsyncComputed', - category: 'VariableDeclarator', - data: asyncComputedValue - }); - + ); collecthook({ variableName: 'asyncComputedValue', hookType: 'useAsyncComputed', category: 'VariableDeclarator', data: asyncComputedValue }); useContextProvider(ButtonContext, { theme: 'primary', size: 'large' - }); - collecthook({ - variableName: 'useContextProvider', - hookType: 'useContextProvider', - category: 'expressionStatement', - data: undefined - }); - - let _customhook_0 = useHooks('Button'); - collecthook({ - variableName: '_customhook_0', - hookType: 'customhook', - category: 'VariableDeclarator', - data: _customhook_0 - }); - - const context = useContext(ButtonContext); - collecthook({ - variableName: 'context', - hookType: 'useContext', - category: 'VariableDeclarator', - data: context - }); - - - const buttonId = useId(); - collecthook({ - variableName: 'buttonId', - hookType: 'useId', - category: 'VariableDeclarator', - data: buttonId - }); + }); collecthook({ variableName: 'useContextProvider', hookType: 'useContextProvider', category: 'expressionStatement', data: undefined }); + let _customhook_0 = useHooks('Button'); collecthook({ variableName: '_customhook_0', hookType: 'customhook', category: 'VariableDeclarator', data: _customhook_0 }); + const context = useContext(ButtonContext); collecthook({ variableName: 'context', hookType: 'useContext', category: 'VariableDeclarator', data: context }); + const buttonId = useId(); collecthook({ variableName: 'buttonId', hookType: 'useId', category: 'VariableDeclarator', data: buttonId }); useOnDocument('keydown', $(() => { console.log('Document keydown event'); - })); - collecthook({ - variableName: 'useOnDocument', - hookType: 'useOnDocument', - category: 'expressionStatement', - data: undefined - }); - - const dd = useHooks('Button1') - collecthook({ - variableName: 'dd', - hookType: 'customhook', - category: 'VariableDeclarator', - data: dd - }); - + })); collecthook({ variableName: 'useOnDocument', hookType: 'useOnDocument', category: 'expressionStatement', data: undefined }); + const dd = useHooks('Button1'); collecthook({ variableName: 'dd', hookType: 'customhook', category: 'VariableDeclarator', data: dd }); useOnWindow('resize', $(() => { console.log('Window resized'); - })); - collecthook({ - variableName: 'useOnWindow', - hookType: 'useOnWindow', - category: 'expressionStatement', - data: undefined - }); - + })); collecthook({ variableName: 'useOnWindow', hookType: 'useOnWindow', category: 'expressionStatement', data: undefined }); useOn('click', $(() => { console.log('Host clicked'); - })); - collecthook({ - variableName: 'useOn', - hookType: 'useOn', - category: 'expressionStatement', - data: undefined - }); - + })); collecthook({ variableName: 'useOn', hookType: 'useOn', category: 'expressionStatement', data: undefined }); const resourceData = useResource$(async ({ track }) => { track(() => store.count); @@ -255,14 +114,7 @@ export default component$((props) => { message: \`Resource data for count: \${store.count}\`, timestamp: Date.now() }; - }); - collecthook({ - variableName: 'resourceData', - hookType: 'useResource', - category: 'VariableDeclarator', - data: resourceData - }); - + }); collecthook({ variableName: 'resourceData', hookType: 'useResource', category: 'VariableDeclarator', data: resourceData }); const customSerialized = useSerializer$(() => ({ deserialize: () => ({ n: store.count }), @@ -270,14 +122,7 @@ export default component$((props) => { current.n = store.count; return current; } - })); - collecthook({ - variableName: 'customSerialized', - hookType: 'useSerializer', - category: 'VariableDeclarator', - data: customSerialized - }); - + })); collecthook({ variableName: 'customSerialized', hookType: 'useSerializer', category: 'VariableDeclarator', data: customSerialized }); useStyles$(\` .custom-button { @@ -292,14 +137,7 @@ export default component$((props) => { .custom-button:hover { transform: scale(1.05); } - \`); - collecthook({ - variableName: 'useStyles', - hookType: 'useStyles', - category: 'expressionStatement', - data: undefined - }); - + \`); collecthook({ variableName: 'useStyles', hookType: 'useStyles', category: 'expressionStatement', data: undefined }); useStylesScoped$(\` .scoped-button { @@ -310,27 +148,13 @@ export default component$((props) => { border-radius: 3px; cursor: pointer; } - \`); - collecthook({ - variableName: 'useStylesScoped', - hookType: 'useStylesScoped', - category: 'expressionStatement', - data: undefined - }); - + \`); collecthook({ variableName: 'useStylesScoped', hookType: 'useStylesScoped', category: 'expressionStatement', data: undefined }); const debounce = useDebouncer( $((value: string) => { signal.value = value; }), 1000 - ); - collecthook({ - variableName: 'debounce', - hookType: 'customhook', - category: 'VariableDeclarator', - data: debounce - }); - + ); collecthook({ variableName: 'debounce', hookType: 'customhook', category: 'VariableDeclarator', data: debounce }); const handleClick = $(async () => { diff --git a/packages/devtools/plugin/src/parse/helpers.ts b/packages/devtools/plugin/src/parse/helpers.ts index 19f98b12099..d5478236352 100644 --- a/packages/devtools/plugin/src/parse/helpers.ts +++ b/packages/devtools/plugin/src/parse/helpers.ts @@ -77,36 +77,9 @@ export function isCustomHook(hookName: string): boolean { } // ============================================================================ -// Code Position Utilities +// Statement Utilities // ============================================================================ -/** Finds the start position of the line containing the given index */ -export function findLineStart(code: string, index: number): number { - for (let i = index - 1; i >= 0; i--) { - const ch = code[i]; - if (ch === '\n' || ch === '\r') { - return i + 1; - } - } - return 0; -} - -/** Reads the indentation (spaces/tabs) starting from a given position */ -export function readIndent(code: string, indexFrom: number): string { - let indent = ''; - let i = indexFrom; - while (i < code.length) { - const ch = code[i]; - if (ch === ' ' || ch === '\t') { - indent += ch; - i++; - } else { - break; - } - } - return indent; -} - /** Removes trailing semicolon and whitespace from a code segment */ export function trimStatementSemicolon(segment: string): string { return segment.trim().replace(/;?\s*$/, ''); @@ -118,21 +91,14 @@ export function trimStatementSemicolon(segment: string): string { export type CollecthookCategory = 'VariableDeclarator' | 'expressionStatement'; -/** Builds the collecthook() call code with proper indentation */ +/** Builds a single-line collecthook() call so injected tracking never adds source lines */ export function buildCollecthookPayload( - indent: string, variableName: string, hookType: string, category: CollecthookCategory, hookExpression: string | 'undefined' ): string { - const dataValue = hookExpression === 'undefined' ? 'undefined' : hookExpression; - return `${indent}collecthook({ -${indent} variableName: '${variableName}', -${indent} hookType: '${hookType}', -${indent} category: '${category}', -${indent} data: ${dataValue} -${indent}});\n`; + return `collecthook({ variableName: '${variableName}', hookType: '${hookType}', category: '${category}', data: ${hookExpression} });`; } /** Checks if a collecthook call already exists after the given position (by variable ID) */ diff --git a/packages/devtools/plugin/src/parse/hookTracker.ts b/packages/devtools/plugin/src/parse/hookTracker.ts index 54164f3183c..f82ff47d112 100644 --- a/packages/devtools/plugin/src/parse/hookTracker.ts +++ b/packages/devtools/plugin/src/parse/hookTracker.ts @@ -10,8 +10,6 @@ import { getVariableIdentifierName, isKnownHook, normalizeQrlHookName, - findLineStart, - readIndent, buildCollecthookPayload, hasCollecthookAfterByVariableId, hasCollecthookAfterByVariableName, @@ -19,7 +17,7 @@ import { isCustomHook, } from './helpers'; import { INNER_USE_HOOK } from '@qwik.dev/devtools/kit'; -import type { InjectionTask } from './types'; +import type { InjectionTask, InsertTask } from './types'; import { applySourceEdits } from './sourceEdits'; // ============================================================================ @@ -86,7 +84,7 @@ function processVariableDeclarator( return null; } - const { declEnd, indent } = getPositionInfo(code, range); + const [, declEnd] = range; // Custom hook if (isCustomHook(normalizedName)) { @@ -94,13 +92,12 @@ function processVariableDeclarator( return null; } const payload = buildCollecthookPayload( - indent, variableId, 'customhook', 'VariableDeclarator', variableId ); - return { kind: 'insert', pos: declEnd, text: '\n' + payload }; + return insertAfterStatement(code, declEnd, payload); } // Known hook @@ -112,13 +109,12 @@ function processVariableDeclarator( } const payload = buildCollecthookPayload( - indent, variableId, normalizedName, 'VariableDeclarator', variableId ); - return { kind: 'insert', pos: declEnd, text: '\n' + payload }; + return insertAfterStatement(code, declEnd, payload); } // ============================================================================ @@ -147,8 +143,6 @@ function processExpressionStatement( } const [stmtStart, stmtEnd] = stmtRange; - const lineStart = findLineStart(code, stmtStart); - const indent = readIndent(code, lineStart); // Known hook (expression form) if (isKnownHook(normalizedName)) { @@ -156,21 +150,20 @@ function processExpressionStatement( return null; } const payload = buildCollecthookPayload( - indent, normalizedName, normalizedName, 'expressionStatement', 'undefined' ); return { - task: { kind: 'insert', pos: stmtEnd, text: '\n' + payload }, + task: insertAfterStatement(code, stmtEnd, payload), newIndex: currentIndex, }; } // Custom hook (expression form) - convert to variable declaration if (isCustomHook(normalizedName)) { - return convertToVariableDeclaration(code, stmtStart, stmtEnd, indent, currentIndex); + return convertToVariableDeclaration(code, stmtStart, stmtEnd, currentIndex); } return null; @@ -181,14 +174,11 @@ function convertToVariableDeclaration( code: string, stmtStart: number, stmtEnd: number, - indent: string, currentIndex: number ): { task: InjectionTask; newIndex: number } { const callSource = code.slice(stmtStart, stmtEnd); const variableName = `_customhook_${currentIndex}`; - const declLine = `${indent}let ${variableName} = ${trimStatementSemicolon(callSource)};\n`; const payload = buildCollecthookPayload( - indent, variableName, 'customhook', 'VariableDeclarator', @@ -200,7 +190,7 @@ function convertToVariableDeclaration( kind: 'replace', start: stmtStart, end: stmtEnd, - text: declLine + payload, + text: `let ${variableName} = ${trimStatementSemicolon(callSource)}; ${payload}`, }, newIndex: currentIndex + 1, }; @@ -255,6 +245,12 @@ function extractHookCall(node: unknown): { hookName: string; normalizedName: str // Position Helpers // ============================================================================ +/** Inserts on the statement's own line, adding the `;` it may lack because of ASI */ +function insertAfterStatement(code: string, statementEnd: number, statement: string): InsertTask { + const separator = code[statementEnd - 1] === ';' ? ' ' : '; '; + return { kind: 'insert', pos: statementEnd, text: separator + statement }; +} + function getParentRange(parent: any): [number, number] | null { const range = parent?.range as number[] | undefined; if (!range) { @@ -262,13 +258,3 @@ function getParentRange(parent: any): [number, number] | null { } return [range[0], range[1]]; } - -function getPositionInfo( - code: string, - range: [number, number] -): { declStart: number; declEnd: number; indent: string } { - const [declStart, declEnd] = range; - const lineStart = findLineStart(code, declStart); - const indent = readIndent(code, lineStart); - return { declStart, declEnd, indent }; -} diff --git a/packages/devtools/plugin/src/parse/initInjector.ts b/packages/devtools/plugin/src/parse/initInjector.ts index cf9212be44b..e1558080f1a 100644 --- a/packages/devtools/plugin/src/parse/initInjector.ts +++ b/packages/devtools/plugin/src/parse/initInjector.ts @@ -4,7 +4,6 @@ */ import type { ComponentBodyRange } from './componentBodies'; -import { readIndent } from './helpers'; import { INNER_USE_HOOK } from '@qwik.dev/devtools/kit'; import type { InjectOptions, InsertTask } from './types'; import { applySourceEdits } from './sourceEdits'; @@ -48,15 +47,11 @@ function createInitTask( return null; } - // Calculate insertion position - const { insertIndex, prefixNewline } = calculateInsertPosition(code, insertPos); - const indent = readIndent(code, insertIndex); - - // Build initialization code + // Stay on the body's opening line so source line numbers don't shift. const componentArg = buildComponentArg(options?.path, exportName); - const initLine = `${prefixNewline}${indent}const collecthook = ${INNER_USE_HOOK}(${componentArg})\n`; + const initStatement = ` const collecthook = ${INNER_USE_HOOK}(${componentArg});`; - return { kind: 'insert', pos: insertIndex, text: initLine }; + return { kind: 'insert', pos: insertPos, text: initStatement }; } // ============================================================================ @@ -68,22 +63,6 @@ function hasExistingCollecthook(code: string, insertPos: number): boolean { return /const\s+collecthook\s*=\s*useCollectHooks\s*\(/.test(lookahead); } -function calculateInsertPosition( - code: string, - insertPos: number -): { - insertIndex: number; - prefixNewline: string; -} { - if (code[insertPos] === '\r' && code[insertPos + 1] === '\n') { - return { insertIndex: insertPos + 2, prefixNewline: '' }; - } - if (code[insertPos] === '\n') { - return { insertIndex: insertPos + 1, prefixNewline: '' }; - } - return { insertIndex: insertPos, prefixNewline: '\n' }; -} - // ============================================================================ // Code Builders // ============================================================================ diff --git a/packages/devtools/plugin/src/parse/parse.unit.ts b/packages/devtools/plugin/src/parse/parse.unit.ts index 28992fbb3a7..ffc8b703f55 100644 --- a/packages/devtools/plugin/src/parse/parse.unit.ts +++ b/packages/devtools/plugin/src/parse/parse.unit.ts @@ -232,7 +232,7 @@ describe('injectCollectHooks', () => { ); }); - it('inserts initialization at the very beginning of component$ body with proper indent', () => { + it('inserts initialization at the very beginning of component$ body, on its opening line', () => { const input = sampleVarDecl; const output = parseQwikCode(input, { path: '/abs/path/Button.tsx?id=abc' }); const initLine = 'const collecthook = useCollectHooks("/abs/path/Button.tsx_default")'; @@ -242,8 +242,7 @@ describe('injectCollectHooks', () => { expect(initIdx).toBeGreaterThan(-1); expect(firstStmtIdx).toBeGreaterThan(-1); expect(initIdx).toBeLessThan(firstStmtIdx); - const initLineWithIndent = '\n ' + initLine; - expect(output).toContain(initLineWithIndent); + expect(output).toContain(`{ ${initLine};`); }); it('is idempotent: running twice does not duplicate init', () => { @@ -335,9 +334,7 @@ export default component$(function(props){ const output = parseQwikCode(src, { path: 'CUSTOM_PATH' }); // import is handled by plugin layer; parseQwikCode only injects init and payloads expect( - /component\$\(function\([^)]*\)\s*\{[\r\n]+\s*const collecthook = useCollectHooks\(/m.test( - output - ) + /component\$\(function\([^)]*\)\s*\{ const collecthook = useCollectHooks\(/m.test(output) ).toBe(true); const decl = 'const signal = useSignal(0);'; const declIdx = output.indexOf(decl); @@ -356,8 +353,9 @@ export default component$(function(props){ const afterDecl = output.slice(declIdx); const match = afterDecl.match(hookBlock); expect(match).not.toBeNull(); - expect(output).toContain('\n collecthook({'); - expect(output).toContain('\n data: signal'); + expect(output).toContain( + "const signal = useSignal('111'); collecthook({ variableName: 'signal'," + ); }); it('injects for custom use*-not-in-list (e.g. useDebouncer)', () => { diff --git a/packages/devtools/plugin/src/plugin/devtools.ts b/packages/devtools/plugin/src/plugin/devtools.ts index 7edd2f62369..db5751867f6 100644 --- a/packages/devtools/plugin/src/plugin/devtools.ts +++ b/packages/devtools/plugin/src/plugin/devtools.ts @@ -46,6 +46,7 @@ export function devtoolsPlugin(opts: QwikDevtoolsOptions = {}): Plugin { } }, + // Runs before the optimizer, which has no input sourcemap: never add or remove source lines. transform: { order: 'pre', handler(code, id) { diff --git a/packages/devtools/plugin/src/transforms/root-transform.ts b/packages/devtools/plugin/src/transforms/root-transform.ts index 564bcf1dcb1..1dc329ee109 100644 --- a/packages/devtools/plugin/src/transforms/root-transform.ts +++ b/packages/devtools/plugin/src/transforms/root-transform.ts @@ -1,7 +1,7 @@ import { normalizeExcludePathnames } from '../../../kit/src/overlay-paths'; -import { readIndent } from '../parse/helpers'; import { applySourceEdits } from '../parse/sourceEdits'; import { parseProgram, traverseProgram } from '../parse/traverse'; +import { prependImportIfMissing } from './source-utils'; export interface QwikDevtoolsOptions { overlay?: { @@ -14,34 +14,22 @@ const STYLES_IMPORT = `import '@qwik.dev/devtools/ui/styles.css';`; export function transformRootFile(code: string, opts: QwikDevtoolsOptions = {}): string { const excludePathnames = normalizeExcludePathnames(opts.overlay?.excludePathnames); - const bodyInsertion = findBodyInsertion(code); - if (bodyInsertion !== null) { + const bodyClosingStart = findBodyClosingStart(code); + if (bodyClosingStart !== null) { const devtoolsElement = excludePathnames.length > 0 ? `` : ''; code = applySourceEdits(code, [ - { - kind: 'insert', - pos: bodyInsertion.pos, - text: `${bodyInsertion.prefix}${devtoolsElement}${bodyInsertion.suffix}`, - }, + { kind: 'insert', pos: bodyClosingStart, text: devtoolsElement }, ]); } - if (!code.includes(DEVTOOLS_IMPORT)) { - code = `${DEVTOOLS_IMPORT}\n${code}`; - } - - if (!code.includes(STYLES_IMPORT)) { - code = `${STYLES_IMPORT}\n${code}`; - } - - return code; + return prependImportIfMissing(prependImportIfMissing(code, DEVTOOLS_IMPORT), STYLES_IMPORT); } -function findBodyInsertion(code: string): { pos: number; prefix: string; suffix: string } | null { - const edits: BodyInsertionEdit[] = []; +function findBodyClosingStart(code: string): number | null { + let closingStart: number | null = null; try { const program = parseProgram(code); @@ -52,15 +40,7 @@ function findBodyInsertion(code: string): { pos: number; prefix: string; suffix: return; } - const closingStart = node.closingElement.start; - const lineStart = findLineStart(code, closingStart); - const closingIndent = readIndent(code, lineStart); - const hasOnlyIndentBeforeClosing = code.slice(lineStart, closingStart).trim() === ''; - edits.push({ - pos: hasOnlyIndentBeforeClosing ? lineStart : closingStart, - prefix: hasOnlyIndentBeforeClosing ? `${closingIndent} ` : `\n${closingIndent} `, - suffix: hasOnlyIndentBeforeClosing ? '\n' : '', - }); + closingStart = node.closingElement.start; path.stop(); }, }); @@ -68,18 +48,7 @@ function findBodyInsertion(code: string): { pos: number; prefix: string; suffix: return null; } - const edit = edits[0]; - return edit ?? null; -} - -function findLineStart(code: string, index: number): number { - for (let i = index - 1; i >= 0; i--) { - const ch = code[i]; - if (ch === '\n' || ch === '\r') { - return i + 1; - } - } - return 0; + return closingStart; } function getJsxName(name: unknown): string | undefined { @@ -98,9 +67,3 @@ interface JsxElementNode { start: number; }; } - -interface BodyInsertionEdit { - pos: number; - prefix: string; - suffix: string; -} diff --git a/packages/devtools/plugin/src/transforms/source-utils.ts b/packages/devtools/plugin/src/transforms/source-utils.ts index 7540d05baa5..6a2626a9325 100644 --- a/packages/devtools/plugin/src/transforms/source-utils.ts +++ b/packages/devtools/plugin/src/transforms/source-utils.ts @@ -1,6 +1,11 @@ +/** Prepends on line 1 so the optimizer still sees every source line where it was */ +export function prependImportIfMissing(code: string, importStatement: string): string { + return code.includes(importStatement) ? code : `${importStatement}${code}`; +} + export function injectNamedImportIfMissing(code: string, key: string, name: string): string { if (code.includes(key)) { return code; } - return `import { ${name} } from '${key}';\n${code}`; + return prependImportIfMissing(code, `import { ${name} } from '${key}';`); } diff --git a/packages/devtools/plugin/src/transforms/transforms.unit.ts b/packages/devtools/plugin/src/transforms/transforms.unit.ts index 01c6ec0f8d8..73a6d6793ac 100644 --- a/packages/devtools/plugin/src/transforms/transforms.unit.ts +++ b/packages/devtools/plugin/src/transforms/transforms.unit.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'vitest'; +import { parseSync } from 'oxc-parser'; import { transformComponentFile } from './component-transform'; import { rewriteComponentQrlImport } from './perf-transform'; import { transformRootFile } from './root-transform'; @@ -27,6 +28,17 @@ export default component$(() => { }); `; +// Source locations (data-qwik-inspector, QRL dev spans) are computed after these transforms. +function expectSameSourceLines(source: string, transformed: string) { + const sourceLines = source.split('\n'); + const transformedLines = transformed.split('\n'); + expect(parseSync('file.tsx', transformed, { lang: 'tsx' }).errors).toEqual([]); + expect(transformedLines).toHaveLength(sourceLines.length); + sourceLines.forEach((line, index) => { + expect(transformedLines[index]).toContain(line.trim()); + }); +} + describe('transform facades', () => { test('component transform injects useCollectHooks import and collecthook init', () => { const transformed = transformComponentFile( @@ -74,7 +86,42 @@ export default component$(() => { const transformed = transformRootFile(source); expect(transformed).toContain("const fakeHtml = '
String body
';"); - expect(transformed).toContain('
Real body
\n '); + expect(transformed).toContain('
Real body
\n '); + }); + + test('component transform keeps every source line on its original line number', () => { + const source = `import { component$, useSignal, useStore, useTask$ } from '@qwik.dev/core'; +import { useCounter, useLogger } from './hooks'; + +export const Counter = component$(() => { + const count = useSignal(0) + const state = useStore({ items: [] }); + const local = useCounter(); + useLogger(); + useTask$(({ track }) => { + track(() => count.value); + }); + return ( + + ); +}); + +export default component$(() => ); +`; + + const transformed = transformComponentFile(source, '/repo/src/components/counter.tsx'); + + expect(transformed).toContain('collecthook('); + expectSameSourceLines(source, transformed); + }); + + test('root transform keeps every source line on its original line number', () => { + const transformed = transformRootFile(ROOT_SOURCE); + + expect(transformed).toContain(''); + expectSameSourceLines(ROOT_SOURCE, transformed); }); test('perf transform rewrites componentQrl import', () => { diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index c95f2851b6e..c3f5f672e6b 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -132,6 +132,7 @@ export function qwikMcp(): Plugin { ); } }, + // Runs before the optimizer, which has no input sourcemap: never add or remove source lines. transform(code, id) { if ( /\.[jt]sx(?:\?|$)/.test(id) && diff --git a/packages/mcp/src/integration.test.ts b/packages/mcp/src/integration.test.ts index c7a62384acf..e59787380af 100644 --- a/packages/mcp/src/integration.test.ts +++ b/packages/mcp/src/integration.test.ts @@ -175,6 +175,12 @@ for (const mode of ['csr', 'ssr']) { expect(await page.locator('script[src*="virtual:qwik-mcp"]').count()).toBe(1); await page.locator('#counter').waitFor(); expect(pageErrors).toEqual([]); + if (mode === 'ssr') { + // Hook instrumentation must not shift the source lines the optimizer reports. + expect(await page.locator('#counter').getAttribute('data-qwik-inspector')).toMatch( + /\/routes\/index\.tsx:5:5$/ + ); + } await expect .poll(async () => (await call('inspect_page')).structuredContent?.tree) .toEqual(expect.arrayContaining([expect.objectContaining({ id: expect.any(String) })]));