Files
next.js/test/e2e/webpack-loader-parse-error/webpack-loader-parse-error.test.ts
T
261922df82 Show generated code from loaders in parse error messages (#89898)
## What?

When a webpack/turbopack loader produces broken code, error messages now
display **both** the original source and the generated code with source
map information, making it much easier to debug loader issues.

## Why?

Previously, when loaders returned invalid code, error messages only
showed the original source file (after source-map remapping). Users had
no way to see what the loader actually generated, making it hard to
diagnose why the code failed to parse. Showing both sides gives full
context about what went wrong.

## How?

### Turbopack Core (`turbopack-core`)

- **`Source::description()`** — New method on the `Source` trait
providing human-readable descriptions of where code comes from.
Implemented across all source types (`FileSource`, `VirtualSource`,
`WebpackLoadersProcessedAsset`, `PostCssTransformedAsset`, etc.),
producing chains like `"loaders [sass-loader] transform of file content
of ./styles.scss"`.
- **`AdditionalIssueSource`** — New struct to hold a labeled source
location. The `Issue` trait gains an `additional_sources()` method so
issues can expose supplementary code frames.
- **`GeneratedCodeSource`** — A wrapper that strips `GenerateSourceMap`
support from a source, ensuring the *generated* code is displayed as-is
rather than being remapped back to the original.
- **`IssueSource::to_generated_code_source()`** — Helper that detects
sources implementing `GenerateSourceMap` and wraps them in
`GeneratedCodeSource` for display. Used by `AnalyzeIssue` and
`ParsingIssue` to automatically attach generated code frames.

### Error Formatting

- **`turbopack-cli-utils`** — Renders additional sources in CLI issue
output.
- **`format-issue.ts`** — Renders additional sources in the browser
error overlay. Extracted `formatSourceCodeFrame()` helper to deduplicate
code-frame rendering between primary and additional sources.
- Long-line truncation (e.g. minified CSS from SCSS) is handled natively
by the Rust-based `codeFrameColumns` implementation.

### Type Definitions

- Added `SourcePosition`, `IssueSource`, and `AdditionalIssueSource`
interfaces to TypeScript types.
- Updated `PlainSource` (added `file_path`), `PlainIssue` (added
`additional_sources`), and NAPI bindings to pass the data through.

### Test Coverage

- **E2e tests** (`test/e2e/webpack-loader-parse-error/`) with custom
broken JS and CSS loaders, covering all 4 modes:
- **Development (Turbopack)** — Verifies parse errors show both original
and generated code via browser error overlay
- **Development (Webpack)** — Verifies error overlay shows the parse
error (webpack doesn't support additional sources)
- **Production (Turbopack)** — Verifies build failure output with full
error extraction and inline snapshots
- **Production (Webpack)** — Verifies build failure output with inline
snapshots
- Updated `test/development/sass-error/` snapshot to include the new
generated code frame for minified SCSS output.

### Example Output

When a loader produces broken code, users now see:
```
⨯ ./app/data.broken.js:3:1
Parsing ecmascript source code failed
  1 | // This file will be processed by broken-js-loader
  2 | // The loader will return invalid JavaScript with a source map
> 3 | export default function Data() {
    | ^
  4 |   return <div>original source content</div>
  5 | }
  6 |

Expected '</', got '{'

Generated code of loaders [./broken-js-loader.js] transform of file content of app/data.broken.js:
./app/data.broken.js:3:46
  1 | // Generated by broken-js-loader
  2 | export default function Page() {
> 3 |   return <div>this is intentionally broken {{{ invalid jsx
    |                                              ^
  4 | }
  5 |

Import trace:
  Server Component:
    ./app/data.broken.js
    ./app/page.js
```

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Tobias Koppers <[email protected]>
Co-authored-by: Luke Sandberg <[email protected]>
2026-03-16 13:41:29 +01:00

300 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
import stripAnsi from 'strip-ansi'
/**
* Strips the dynamic test directory prefix from paths so snapshots are stable.
* Handles both absolute paths (/full/path/test/tmp/next-test-XXX/) and
* relative paths (./test/tmp/next-test-XXX/).
*/
function normalizePaths(output: string, testDir: string): string {
let result = output.replaceAll(testDir + '/', '')
const relMatch = testDir.match(/(test\/tmp\/next-test-[^/]+)/)
if (relMatch) {
result = result.replaceAll('./' + relMatch[1] + '/', './')
result = result.replaceAll(relMatch[1] + '/', '')
}
return result
}
/**
* Extracts a single error block from CLI output by searching for a title string.
*
* Start boundary: the nearest `⨯` marker (dev) or file-path line (prod)
* before the title.
* End boundary: the first blank line after an "Import trace" section that
* isn't followed by continuation content (indented lines or file paths).
*/
function extractErrorBlock(output: string, errorTitle: string): string {
const titleIdx = output.indexOf(errorTitle)
if (titleIdx === -1) return ''
const beforeTitle = output.substring(0, titleIdx)
// In dev mode, errors start with a `⨯` marker on the same line as the file path.
// In prod mode, errors start with a bare file-path line above the title.
const markerIdx = beforeTitle.lastIndexOf('⨯ ')
let startIdx: number
if (markerIdx !== -1) {
startIdx = markerIdx
} else {
// Go back two lines: title is preceded by a file-path line (e.g. ./file:3:1)
const lines = beforeTitle.split('\n')
startIdx =
lines.length >= 2
? beforeTitle.length -
lines[lines.length - 1].length -
lines[lines.length - 2].length -
1
: 0
}
// Scan forward to find the end of the Import trace section
const block = output.substring(startIdx)
const lines = block.split('\n')
let inImportTrace = false
for (let i = 0; i < lines.length; i++) {
if (/^Import trace/.test(lines[i])) {
inImportTrace = true
}
// A blank line after the import trace ends the block, unless the next
// line is a continuation (indented or a file path)
if (inImportTrace && lines[i].trim() === '' && i > 0) {
const nextLine = lines[i + 1]
if (
!nextLine ||
(!nextLine.startsWith(' ') && !nextLine.startsWith('./'))
) {
return lines.slice(0, i).join('\n')
}
}
}
return block.trimEnd()
}
describe('webpack-loader-parse-error (development)', () => {
const { next, isTurbopack, isNextDev } = nextTestSetup({
files: __dirname,
skipDeployment: true,
skipStart: true,
})
if (!isNextDev) {
it('skipped in production mode', () => {})
return
}
beforeAll(() => next.start())
it('should show parse error for JS loader that returns broken code', async () => {
const outputIndex = next.cliOutput.length
// Fetch the page to trigger the error
await next.fetch('/')
// Check that the CLI output contains the parse error
// Turbopack: "Parsing ecmascript source code failed"
// Webpack: "Syntax Error"
await retry(async () => {
expect(next.cliOutput).toMatch(
/Parsing ecmascript source code failed|Syntax Error/
)
})
if (isTurbopack) {
const output = normalizePaths(
stripAnsi(next.cliOutput.slice(outputIndex)),
next.testDir
)
const errorBlock = extractErrorBlock(
output,
'Parsing ecmascript source code failed'
)
expect(errorBlock).toMatchInlineSnapshot(`
"⨯ ./app/data.broken.js:3:1
Expected '</', got '{'
1 | // This file will be processed by broken-js-loader
2 | // The loader will return invalid JavaScript with a source map
> 3 | export default function Data() {
| ^
4 | return <div>original source content</div>
5 | }
6 |
Parsing ecmascript source code failed
Generated code of loaders [broken-js-loader.js] transform of file content of app/data.broken.js:
./app/data.broken.js:3:46
1 | // Generated by broken-js-loader
2 | export default function Page() {
> 3 | return <div>this is intentionally broken {{{ invalid jsx
| ^
4 | }
5 |
Import trace:
Server Component:
./app/data.broken.js
./app/page.js"
`)
}
})
it('should show parse error for CSS loader that returns broken code', async () => {
const outputIndex = next.cliOutput.length
// Fetch the page to trigger the CSS error
await next.fetch('/css-page')
// Check that the CLI output contains the CSS parse error
// Turbopack: "Parsing CSS source code failed"
// Webpack: "Unknown word"
await retry(async () => {
expect(next.cliOutput).toMatch(
/Parsing CSS source code failed|Unknown word/
)
})
if (isTurbopack) {
const output = normalizePaths(
stripAnsi(next.cliOutput.slice(outputIndex)),
next.testDir
)
const errorBlock = extractErrorBlock(
output,
'Parsing CSS source code failed'
)
expect(errorBlock).toMatchInlineSnapshot(`
"⨯ ./app/css-page/styles.broken.css:2:3
Parsing CSS source code failed
1 | .page {
> 2 | color: blue;
| ^
3 | font-size: 16px;
4 | }
5 |
Unexpected token CurlyBracketBlock
Generated code of PostCSS transform of loaders [broken-css-loader.js] transform of file content of app/css-page/styles.broken.css:
./app/css-page/styles.broken.css:5:15
3 | color: red
4 | @@@ THIS IS NOT VALID CSS @@@;
> 5 | background: {{{ invalid
| ^
6 | }
7 |
Import trace:
Client Component Browser:
./app/css-page/styles.broken.css [Client Component Browser]
./app/css-page/page.js [Server Component]"
`)
}
})
})
describe('webpack-loader-parse-error (production)', () => {
const { next, isNextStart, isTurbopack } = nextTestSetup({
files: __dirname,
skipDeployment: true,
skipStart: true,
})
if (!isNextStart) {
it('skipped in development mode', () => {})
return
}
it('should fail the build with parse errors from loaders', async () => {
await expect(next.start()).rejects.toThrow(
'next build failed with code/signal 1'
)
const output = normalizePaths(stripAnsi(next.cliOutput), next.testDir)
if (isTurbopack) {
const jsError = extractErrorBlock(output, "Expected '</', got '{'")
expect(jsError).toMatchInlineSnapshot(`
"./app/data.broken.js:3:1
Expected '</', got '{'
1 | // This file will be processed by broken-js-loader
2 | // The loader will return invalid JavaScript with a source map
> 3 | export default function Data() {
| ^
4 | return <div>original source content</div>
5 | }
6 |
Parsing ecmascript source code failed
Generated code of loaders [broken-js-loader.js] transform of file content of app/data.broken.js:
./app/data.broken.js:3:46
1 | // Generated by broken-js-loader
2 | export default function Page() {
> 3 | return <div>this is intentionally broken {{{ invalid jsx
| ^
4 | }
5 |
Import trace:
Server Component:
./app/data.broken.js
./app/page.js"
`)
const cssError = extractErrorBlock(
output,
'Parsing CSS source code failed'
)
expect(cssError).toMatchInlineSnapshot(`
"./app/css-page/styles.broken.css:5:15
Parsing CSS source code failed
3 | color: red
4 | @@@ THIS IS NOT VALID CSS @@@;
> 5 | background: {{{ invalid
| ^
6 | }
7 |
Unexpected token CurlyBracketBlock
Generated code of PostCSS transform of loaders [broken-css-loader.js] transform of file content of app/css-page/styles.broken.css:
./app/css-page/styles.broken.css:5:15
3 | color: red
4 | @@@ THIS IS NOT VALID CSS @@@;
> 5 | background: {{{ invalid
| ^
6 | }
7 |
Import trace:
Client Component Browser:
./app/css-page/styles.broken.css [Client Component Browser]
./app/css-page/page.js [Server Component]"
`)
} else {
// Webpack stops at the first error (JS), showing the generated code
const jsError = extractErrorBlock(output, "Expected '</', got '{'")
expect(jsError).toMatchInlineSnapshot(`
"./app/data.broken.js
Error: x Expected '</', got '{'
,-[3:1]
1 | // Generated by broken-js-loader
2 | export default function Page() {
3 | return <div>this is intentionally broken {{{ invalid jsx
: ^
4 | }
\`----
Caused by:
Syntax Error
Import trace for requested module:
./app/data.broken.js
./app/page.js"
`)
}
})
})