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/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; 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) })]));