mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
## 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]>
300 lines
9.2 KiB
TypeScript
300 lines
9.2 KiB
TypeScript
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"
|
||
`)
|
||
}
|
||
})
|
||
})
|