Skip to content

fix(compile-sfc): handle inline template source map in prod build #12701

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
May 20, 2025
Merged
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
29 changes: 28 additions & 1 deletion packages/compiler-sfc/__tests__/compileScript.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { BindingTypes } from '@vue/compiler-core'
import { assertCode, compileSFCScript as compile, mockId } from './utils'
import {
assertCode,
compileSFCScript as compile,
getPositionInCode,
mockId,
} from './utils'
import { type RawSourceMap, SourceMapConsumer } from 'source-map-js'

describe('SFC compile <script setup>', () => {
test('should compile JS syntax', () => {
Expand Down Expand Up @@ -690,6 +696,27 @@ describe('SFC compile <script setup>', () => {
expect(content).toMatch(`new (_unref(Foo)).Bar()`)
assertCode(content)
})

// #12682
test('source map', () => {
const source = `
<script setup>
const count = ref(0)
</script>
<template>
<button @click="throw new Error(\`msg\`);"></button>
</template>
`
const { content, map } = compile(source, { inlineTemplate: true })
expect(map).not.toBeUndefined()
const consumer = new SourceMapConsumer(map as RawSourceMap)
expect(
consumer.originalPositionFor(getPositionInCode(content, 'count')),
).toMatchObject(getPositionInCode(source, `count`))
expect(
consumer.originalPositionFor(getPositionInCode(content, 'Error')),
).toMatchObject(getPositionInCode(source, `Error`))
})
})

describe('with TypeScript', () => {
Expand Down
34 changes: 1 addition & 33 deletions packages/compiler-sfc/__tests__/compileTemplate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from '../src/compileTemplate'
import { type SFCTemplateBlock, parse } from '../src/parse'
import { compileScript } from '../src'
import { getPositionInCode } from './utils'

function compile(opts: Omit<SFCTemplateCompileOptions, 'id'>) {
return compileTemplate({
Expand Down Expand Up @@ -511,36 +512,3 @@ test('non-identifier expression in legacy filter syntax', () => {
babelParse(compilationResult.code, { sourceType: 'module' })
}).not.toThrow()
})

interface Pos {
line: number
column: number
name?: string
}

function getPositionInCode(
code: string,
token: string,
expectName: string | boolean = false,
): Pos {
const generatedOffset = code.indexOf(token)
let line = 1
let lastNewLinePos = -1
for (let i = 0; i < generatedOffset; i++) {
if (code.charCodeAt(i) === 10 /* newline char code */) {
line++
lastNewLinePos = i
}
}
const res: Pos = {
line,
column:
lastNewLinePos === -1
? generatedOffset
: generatedOffset - lastNewLinePos - 1,
}
if (expectName) {
res.name = typeof expectName === 'string' ? expectName : token
}
return res
}
33 changes: 33 additions & 0 deletions packages/compiler-sfc/__tests__/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,36 @@ export function assertCode(code: string): void {
}
expect(code).toMatchSnapshot()
}

interface Pos {
line: number
column: number
name?: string
}

export function getPositionInCode(
code: string,
token: string,
expectName: string | boolean = false,
): Pos {
const generatedOffset = code.indexOf(token)
let line = 1
let lastNewLinePos = -1
for (let i = 0; i < generatedOffset; i++) {
if (code.charCodeAt(i) === 10 /* newline char code */) {
line++
lastNewLinePos = i
}
}
const res: Pos = {
line,
column:
lastNewLinePos === -1
? generatedOffset
: generatedOffset - lastNewLinePos - 1,
}
if (expectName) {
res.name = typeof expectName === 'string' ? expectName : token
}
return res
}
Comment on lines +50 to +75
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Guard against “token not found” and CRLF line endings

indexOf() can return -1 ‑ leading to line=1 and column=-1, which later feeds SourceMapConsumer.originalPositionFor() with negative values.
In addition, the loop only treats \n (LF) as a newline; on Windows a CRLF sequence will mis-count lines.

+  if (generatedOffset === -1) {
+    throw new Error(`Token "${token}" not found in supplied code snippet`)
+  }
   for (let i = 0; i < generatedOffset; i++) {
-    if (code.charCodeAt(i) === 10 /* newline char code */) {
+    const ch = code.charCodeAt(i)
+    // 10 → \n  |  13 → \r
+    if (ch === 10 || ch === 13) {
       line++
       lastNewLinePos = i
     }
   }

This eliminates silent mis-mappings and makes the helper reliable across OSes.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In packages/compiler-sfc/__tests__/utils.ts between lines 50 and 75, the
function getPositionInCode does not handle the case when the token is not found,
causing indexOf to return -1 and resulting in invalid line and column values.
Additionally, it only counts '\n' as a newline, which fails on Windows CRLF line
endings. Fix this by adding a guard to return a default or error position if
token is not found, and update the loop to correctly count both '\r\n' and '\n'
as newlines to ensure accurate line and column calculation across OSes.

76 changes: 65 additions & 11 deletions packages/compiler-sfc/src/compileScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ import type {
Statement,
} from '@babel/types'
import { walk } from 'estree-walker'
import type { RawSourceMap } from 'source-map-js'
import {
type RawSourceMap,
SourceMapConsumer,
SourceMapGenerator,
} from 'source-map-js'
import {
normalScriptDefaultVar,
processNormalScript,
Expand Down Expand Up @@ -809,6 +813,7 @@ export function compileScript(
args += `, { ${destructureElements.join(', ')} }`
}

let templateMap
// 9. generate return statement
let returned
if (
Expand Down Expand Up @@ -858,7 +863,7 @@ export function compileScript(
}
// inline render function mode - we are going to compile the template and
// inline it right here
const { code, ast, preamble, tips, errors } = compileTemplate({
const { code, ast, preamble, tips, errors, map } = compileTemplate({
filename,
ast: sfc.template.ast,
source: sfc.template.content,
Expand All @@ -876,6 +881,7 @@ export function compileScript(
bindingMetadata: ctx.bindingMetadata,
},
})
templateMap = map
if (tips.length) {
tips.forEach(warnOnce)
}
Expand Down Expand Up @@ -1014,19 +1020,28 @@ export function compileScript(
)
}

const content = ctx.s.toString()
let map =
options.sourceMap !== false
? (ctx.s.generateMap({
source: filename,
hires: true,
includeContent: true,
}) as unknown as RawSourceMap)
: undefined
// merge source maps of the script setup and template in inline mode
if (templateMap && map) {
const offset = content.indexOf(returned)
const templateLineOffset =
content.slice(0, offset).split(/\r?\n/).length - 1
map = mergeSourceMaps(map, templateMap, templateLineOffset)
}
return {
...scriptSetup,
bindings: ctx.bindingMetadata,
imports: ctx.userImports,
content: ctx.s.toString(),
map:
options.sourceMap !== false
? (ctx.s.generateMap({
source: filename,
hires: true,
includeContent: true,
}) as unknown as RawSourceMap)
: undefined,
content,
map,
scriptAst: scriptAst?.body,
scriptSetupAst: scriptSetupAst?.body,
deps: ctx.deps ? [...ctx.deps] : undefined,
Expand Down Expand Up @@ -1284,3 +1299,42 @@ function isStaticNode(node: Node): boolean {
}
return false
}

export function mergeSourceMaps(
scriptMap: RawSourceMap,
templateMap: RawSourceMap,
templateLineOffset: number,
): RawSourceMap {
const generator = new SourceMapGenerator()
const addMapping = (map: RawSourceMap, lineOffset = 0) => {
const consumer = new SourceMapConsumer(map)
;(consumer as any).sources.forEach((sourceFile: string) => {
;(generator as any)._sources.add(sourceFile)
const sourceContent = consumer.sourceContentFor(sourceFile)
if (sourceContent != null) {
generator.setSourceContent(sourceFile, sourceContent)
}
})
consumer.eachMapping(m => {
if (m.originalLine == null) return
generator.addMapping({
generated: {
line: m.generatedLine + lineOffset,
column: m.generatedColumn,
},
original: {
line: m.originalLine,
column: m.originalColumn,
},
source: m.source,
name: m.name,
})
})
}

addMapping(scriptMap)
addMapping(templateMap, templateLineOffset)
;(generator as any)._sourceRoot = scriptMap.sourceRoot
;(generator as any)._file = scriptMap.file
return (generator as any).toJSON()
}