Skip to content
Open
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
25 changes: 23 additions & 2 deletions packages/next/src/shared/lib/format-webpack-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,20 @@ function formatMessage(
}
let lines = message.split('\n')

// Strip Webpack-added headers off errors/warnings
// Extract loader paths from Webpack-added headers and move them to end.
// Original format: "Module build failed (from ./loaders/foo-loader.js):"
// The header line is removed and the path is appended at the end as:
// " (from ./loaders/foo-loader.js)"
// https://github.com/webpack/webpack/blob/master/lib/ModuleError.js
lines = lines.filter((line: string) => !/Module [A-z ]+\(from/.test(line))
const loaderPaths: string[] = []
lines = lines.filter((line: string) => {
const match = /Module [A-z ]+\(from (.+)\):?\s*$/.exec(line)
if (match) {
loaderPaths.push(match[1])
return false
}
return true
})

// Transform parsing error into syntax error
// TODO: move this to our ESLint formatter?
Expand Down Expand Up @@ -182,6 +193,16 @@ function formatMessage(
lines = message.split('\n')
}

// Append loader paths at the end (before any remaining stack trace)
if (loaderPaths.length > 0) {
for (const loaderPath of loaderPaths) {
// Don't show internal Next.js loader paths — they're noise for users
if (!/[/\\]next[/\\]dist[/\\]/.test(loaderPath)) {
lines.push(` (from ${loaderPath})`)
}
}
}

// Remove duplicated newlines
lines = (lines as string[]).filter(
(line, index, arr) =>
Expand Down
5 changes: 5 additions & 0 deletions test/e2e/app-dir/webpack-loader-errors/app/error/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const data = require('../../data/error.data')

export default function Page() {
return <p>{data.default}</p>
}
5 changes: 5 additions & 0 deletions test/e2e/app-dir/webpack-loader-errors/app/fs-error/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const data = require('../../data/fs-error.data')

export default function Page() {
return <p>{data.default}</p>
}
8 changes: 8 additions & 0 deletions test/e2e/app-dir/webpack-loader-errors/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ReactNode } from 'react'
export default function Root({ children }: { children: ReactNode }) {
return (
<html>
<body>{children}</body>
</html>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const data = require('../../data/no-stack-error.data')

export default function Page() {
return <p>{data.default}</p>
}
3 changes: 3 additions & 0 deletions test/e2e/app-dir/webpack-loader-errors/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <p>hello world</p>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const data = require('../../data/promise-error.data')

export default function Page() {
return <p>{data.default}</p>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const data = require('../../data/string-error.data')

export default function Page() {
return <p>{data.default}</p>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const data = require('../../data/timeout-error.data')

export default function Page() {
return <p>{data.default}</p>
}
1 change: 1 addition & 0 deletions test/e2e/app-dir/webpack-loader-errors/data/error.data
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default "error-data"
1 change: 1 addition & 0 deletions test/e2e/app-dir/webpack-loader-errors/data/fs-error.data
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default "fs-error-data"
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default "no-stack-error-data"
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default "promise-error-data"
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default "string-error-data"
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default "timeout-error-data"
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = function errorLoader(source) {
throw new Error('An error thrown by error-loader')
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const fs = require('fs')

module.exports = function fsErrorLoader(source) {
fs.readFileSync('/does/not/exist/file.txt')
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = function noStackErrorLoader(source) {
const err = new Error('An error without stack from no-stack-error-loader')
err.stack = undefined
throw err
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module.exports = function promiseErrorLoader(source) {
Promise.reject(new Error('An error thrown by promise-error-loader'))
return source
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = function stringErrorLoader(source) {
// Intentionally throwing a string (not an Error object) to test string error handling
// eslint-disable-next-line no-throw-literal
throw 'A string error thrown by string-error-loader'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = function timeoutErrorLoader(source) {
const callback = this.async()
setTimeout(() => {
throw new Error('An error thrown by timeout-error-loader')
}, 0)
callback(null, source)
}
64 changes: 64 additions & 0 deletions test/e2e/app-dir/webpack-loader-errors/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
turbopack: {
rules: {
'error.data': {
loaders: [require.resolve('./loaders/error-loader.js')],
as: '*.js',
},
'string-error.data': {
loaders: [require.resolve('./loaders/string-error-loader.js')],
as: '*.js',
},
'promise-error.data': {
loaders: [require.resolve('./loaders/promise-error-loader.js')],
as: '*.js',
},
'timeout-error.data': {
loaders: [require.resolve('./loaders/timeout-error-loader.js')],
as: '*.js',
},
'no-stack-error.data': {
loaders: [require.resolve('./loaders/no-stack-error-loader.js')],
as: '*.js',
},
'fs-error.data': {
loaders: [require.resolve('./loaders/fs-error-loader.js')],
as: '*.js',
},
},
},
webpack(config) {
config.module.rules.push(
{
test: /[\\/]error\.data$/,
use: [require.resolve('./loaders/error-loader.js')],
},
{
test: /string-error\.data$/,
use: [require.resolve('./loaders/string-error-loader.js')],
},
{
test: /promise-error\.data$/,
use: [require.resolve('./loaders/promise-error-loader.js')],
},
{
test: /timeout-error\.data$/,
use: [require.resolve('./loaders/timeout-error-loader.js')],
},
{
test: /no-stack-error\.data$/,
use: [require.resolve('./loaders/no-stack-error-loader.js')],
},
{
test: /fs-error\.data$/,
use: [require.resolve('./loaders/fs-error-loader.js')],
}
)
return config
},
}

module.exports = nextConfig
131 changes: 131 additions & 0 deletions test/e2e/app-dir/webpack-loader-errors/webpack-loader-errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { nextTestSetup } from 'e2e-utils'
import { retry, waitForRedbox, getRedboxSource } from 'next-test-utils'
import stripAnsi from 'strip-ansi'

describe('webpack-loader-errors', () => {
const { next, isNextDev, isTurbopack } = nextTestSetup({
files: __dirname,
skipDeployment: true,
skipStart: true,
})

if (!isNextDev) {
it('should skip in non-dev mode', () => {})
return
}

beforeAll(async () => {
await next.start()
})

describe('CLI output', () => {
// Test string-error before error to ensure each error appears independently
// in the CLI output (webpack only shows errors[0] per compilation)
it('should show the loader path and error message when a loader throws a plain string', async () => {
await next.fetch('/string-error')
await retry(
async () => {
const output = stripAnsi(next.cliOutput)
expect(output).toContain('string-error.data')
expect(output).toContain(
'A string error thrown by string-error-loader'
)
expect(output).toMatch(/\(from .+loaders\/string-error-loader/)
},
// webpack compilation output appears asynchronously
30_000
)
})

it('should show the loader path and error message when a loader throws an Error', async () => {
await next.fetch('/error')
await retry(async () => {
const output = stripAnsi(next.cliOutput)
expect(output).toContain('error.data')
expect(output).toContain('An error thrown by error-loader')
expect(output).toMatch(/\(from .+loaders\/error-loader/)
}, 30_000)
})

// The following CLI tests are Turbopack-only because webpack's CLI output
// only logs errors[0] per compilation (see store.ts). When multiple pages
// have errors, only the first error (by module order) is shown. These
// error types still work correctly and are tested via the overlay tests.
if (isTurbopack) {
it('should surface an unhandled rejected Promise from a loader', async () => {
await next.fetch('/promise-error')
await retry(async () => {
const output = stripAnsi(next.cliOutput)
expect(output).toContain('An error thrown by promise-error-loader')
})
})

it('should surface a setTimeout error thrown after loader completion', async () => {
await next.fetch('/timeout-error')
await retry(async () => {
const output = stripAnsi(next.cliOutput)
expect(output).toContain('An error thrown by timeout-error-loader')
})
})

it('should show the loader path and error message when a loader throws an Error without stack', async () => {
await next.fetch('/no-stack-error')
await retry(async () => {
const output = stripAnsi(next.cliOutput)
expect(output).toContain(
'An error without stack from no-stack-error-loader'
)
expect(output).toMatch(/\(from .+loaders\/no-stack-error-loader/)
})
})

it('should show the loader path and error message when a loader throws a filesystem error', async () => {
await next.fetch('/fs-error')
await retry(async () => {
const output = stripAnsi(next.cliOutput)
expect(output).toContain('ENOENT')
expect(output).toMatch(/\(from .+loaders\/fs-error-loader/)
})
})
}
})

// Build errors accumulate globally and the overlay shows the first build
// error (no pagination for build errors). After the CLI tests compile all
// error routes, the overlay may show any accumulated error. So we only
// verify that a loader error with "(from ...)" is displayed, not which
// specific one. The CLI tests above validate each error type specifically.
describe('error overlay', () => {
it('should show error overlay with loader path when a loader throws a plain string', async () => {
const browser = await next.browser('/string-error')
await waitForRedbox(browser)

const source = await getRedboxSource(browser)
expect(source).toMatch(/\(from .+loaders\//)
})

it('should show error overlay with loader path when a loader throws an Error', async () => {
const browser = await next.browser('/error')
await waitForRedbox(browser)

const source = await getRedboxSource(browser)
expect(source).toMatch(/\(from .+loaders\//)
})

it('should show error overlay with loader path when a loader throws an Error without stack', async () => {
const browser = await next.browser('/no-stack-error')
await waitForRedbox(browser)

const source = await getRedboxSource(browser)
expect(source).toMatch(/\(from .+loaders\//)
})

it('should show error overlay with loader path when a loader throws a filesystem error', async () => {
const browser = await next.browser('/fs-error')
await waitForRedbox(browser)

const source = await getRedboxSource(browser)
expect(source).toMatch(/\(from .+loaders\//)
})
})
})
4 changes: 4 additions & 0 deletions turbopack/crates/turbopack-node/js/src/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,10 @@ process.on('uncaughtException', (err) => {
IPC.sendError(err)
})

process.on('unhandledRejection', (reason) => {
IPC.sendError(reason instanceof Error ? reason : new Error(String(reason)))
})

const improveConsole = (name: string, stream: string, addStack: boolean) => {
// @ts-ignore
const original = console[name]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -491,10 +491,39 @@ const transform = (
'**',
]),
})
if (err) return reject(err)
if (err) {
// Resolve loader paths to include in the error message using
// the same "(from ...)" style as webpack's format-webpack-messages.
const loaderPaths = loadersWithOptions
.map((l) => {
try {
return __turbopack_external_require__.resolve(l.loader, {
paths: [contextDir, resourceDir],
})
} catch {
return l.loader
}
})
.join(', ')

if (!(err instanceof Error)) {
// String throws lose their stack trace, so we create a
// synthetic one pointing at the loader.
const wrappedErr = new Error(
`${String(err)}\n (from ${loaderPaths})`
)
wrappedErr.stack = `Error: ${String(err)}\n at loader (${loaderPaths})`
return reject(wrappedErr)
}

// Append "(from ...)" to the error message so the loader path
// is visible in the error overlay, matching webpack's style.
err.message += `\n (from ${loaderPaths})`
return reject(err)
}
if (!result.result) return reject(new Error('No result from loaders'))
const [source, map] = result.result
resolve({
const resolvedValue = {
source: Buffer.isBuffer(source)
? { binary: source.toString('base64') }
: source,
Expand All @@ -504,7 +533,12 @@ const transform = (
: typeof map === 'object'
? JSON.stringify(map)
: undefined,
})
}
// Delay resolution by one event loop turn to catch deferred errors
// from loaders (e.g. unhandled Promise rejections, setTimeout throws).
// During this delay, uncaughtException/unhandledRejection handlers can
// fire and send the error via IPC before we send the 'end' message.
setTimeout(() => resolve(resolvedValue), 0)
}
)
})
Expand Down
Loading